From 3607039ce976708b88b19fd389e934e50cc295dd Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Sun, 21 Sep 2025 14:20:06 +0200 Subject: [PATCH 1/2] Add cookie based auth --- Sources/Grodt/Application/routes.swift | 7 +++- Sources/Grodt/Endpoints/UserController.swift | 11 +++++++ .../Grodt/Persistency/Models/UserToken.swift | 4 +-- .../OriginRefererCheckMiddleware.swift | 33 +++++++++++++++++++ .../UserTokenCookieAuthenticator.swift | 21 ++++++++++++ 5 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 Sources/Grodt/Security/OriginRefererCheckMiddleware.swift create mode 100644 Sources/Grodt/Security/UserTokenCookieAuthenticator.swift diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index 4be4ed4..8b061e8 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), 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) + } + } +} From d6901f0e5c03f509d42b5c5c8ea8a59975fa3beb Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Sun, 21 Sep 2025 14:21:08 +0200 Subject: [PATCH 2/2] Clear expired tokens daily --- Sources/Grodt/Application/routes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index 8b061e8..c3e4211 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -134,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()