diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index 4be4ed4..c3e4211 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -70,7 +70,12 @@ func routes(_ app: Application) async throws { .register(collection: UserController(dtoMapper: loginResponseDTOMapper)) // Protected routes - let protected = api.grouped([tokenAuthMiddleware, guardAuthMiddleware]) + let protected = api.grouped([ + UserTokenCookieAuthenticator(), + tokenAuthMiddleware, + OriginRefererCheckMiddleware(), + guardAuthMiddleware + ]) try protected.register(collection: PortfoliosController( portfolioRepository: PostgresPortfolioRepository(database: app.db), @@ -129,7 +134,7 @@ func routes(_ app: Application) async throws { let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db)) app.queues.schedule(userTokenCleanerJob) - .hourly() + .daily() try app.queues.startScheduledJobs() try app.queues.startInProcessJobs() diff --git a/Sources/Grodt/Endpoints/UserController.swift b/Sources/Grodt/Endpoints/UserController.swift index 24eb39f..d3e7bc6 100644 --- a/Sources/Grodt/Endpoints/UserController.swift +++ b/Sources/Grodt/Endpoints/UserController.swift @@ -17,6 +17,17 @@ struct UserController: RouteCollection { let response = Response(status: .ok) response.headers.add(name: .authorization, value: "Bearer \(token.value)") + let cookieTTL: TimeInterval = UserToken.tokenTTL + response.cookies[UserTokenCookieAuthenticator.tokenName] = .init( + string: token.value, + expires: Date().addingTimeInterval(cookieTTL), + maxAge: Int(cookieTTL), + domain: nil, + path: "/", + isSecure: req.application.environment == .production, + isHTTPOnly: true, + sameSite: .lax + ) return response } diff --git a/Sources/Grodt/Persistency/Models/UserToken.swift b/Sources/Grodt/Persistency/Models/UserToken.swift index 19b05cc..e76d5bd 100644 --- a/Sources/Grodt/Persistency/Models/UserToken.swift +++ b/Sources/Grodt/Persistency/Models/UserToken.swift @@ -57,12 +57,12 @@ extension UserToken { } extension UserToken: ModelTokenAuthenticatable { - static let secondsInHour: Double = 60 * 60 + static let tokenTTL: TimeInterval = 60 * 60 * 24 * 30 // 30 days static let valueKey = \UserToken.$value static let userKey = \UserToken.$user var isValid: Bool { - return creationDate.timeIntervalSince1970 + UserToken.secondsInHour > Date().timeIntervalSince1970 + return creationDate.addingTimeInterval(UserToken.tokenTTL) > Date() } } diff --git a/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift b/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift new file mode 100644 index 0000000..c1ff6bf --- /dev/null +++ b/Sources/Grodt/Security/OriginRefererCheckMiddleware.swift @@ -0,0 +1,33 @@ +import Vapor +import Foundation + +struct OriginRefererCheckMiddleware: AsyncMiddleware { + func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response { + + switch request.method { + case .POST, .PUT, .PATCH, .DELETE: + let origin = request.headers.first(name: .origin) + let referer = request.headers.first(name: .referer) + let host = request.headers.first(name: .host) + + func hostMatches(_ urlString: String?, _ expectedHost: String?) -> Bool { + guard let urlString, let expected = expectedHost, !urlString.isEmpty, !expected.isEmpty else { return true } + let uri = URI(string: urlString) + guard let got = uri.host else { return false } + + return got.lowercased() == expected.split(separator: ":").first!.lowercased() + } + + if let origin, !hostMatches(origin, host) { + throw Abort(.forbidden, reason: "Cross-origin write blocked by Origin policy") + } + if origin == nil, let referer, !hostMatches(referer, host) { + throw Abort(.forbidden, reason: "Cross-origin write blocked by Referer policy") + } + default: + break + } + + return try await next.respond(to: request) + } +} diff --git a/Sources/Grodt/Security/UserTokenCookieAuthenticator.swift b/Sources/Grodt/Security/UserTokenCookieAuthenticator.swift new file mode 100644 index 0000000..336d00d --- /dev/null +++ b/Sources/Grodt/Security/UserTokenCookieAuthenticator.swift @@ -0,0 +1,21 @@ +import Vapor +import Fluent + +struct UserTokenCookieAuthenticator: AsyncRequestAuthenticator { + static let tokenName = "auth_token" + + typealias User = Grodt.User + + func authenticate(request: Request) async throws { + guard let raw = request.cookies[Self.tokenName]?.string, !raw.isEmpty else { + return + } + if let userToken = try await UserToken.query(on: request.db) + .filter(\.$value == raw) + .with(\.$user) + .first(), + userToken.isValid { + request.auth.login(userToken.user) + } + } +}