From d4a2048b127ede6a5bc5a129f76c94c56db425f5 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sun, 26 Jan 2025 21:03:10 -0500 Subject: [PATCH] feat: Adds reset password api route, needs associated view route. --- .../ApiController+live.swift | 3 ++ Sources/DatabaseClient/Users.swift | 1 + Sources/DatabaseClientLive/Users+live.swift | 39 ++++++++++++++++++- Sources/SharedModels/Routes/ApiRoute.swift | 6 +++ Sources/SharedModels/User.swift | 13 +++++++ 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/Sources/ApiControllerLive/ApiController+live.swift b/Sources/ApiControllerLive/ApiController+live.swift index e6f0e73..51abd83 100644 --- a/Sources/ApiControllerLive/ApiController+live.swift +++ b/Sources/ApiControllerLive/ApiController+live.swift @@ -110,6 +110,9 @@ private extension SiteRoute.Api.UserRoute { return user // case let .login(user): // return try await users.login(user) + case let .resetPassword(id: id, request: request): + try await users.resetPassword(id, request) + return nil case let .update(id: id, updates: updates): return try await users.update(id, updates) } diff --git a/Sources/DatabaseClient/Users.swift b/Sources/DatabaseClient/Users.swift index 242feff..02f1522 100644 --- a/Sources/DatabaseClient/Users.swift +++ b/Sources/DatabaseClient/Users.swift @@ -15,6 +15,7 @@ public extension DatabaseClient { public var get: @Sendable (User.ID) async throws -> User? public var login: @Sendable (User.Login) async throws -> User.Token public var logout: @Sendable (User.Token.ID) async throws -> Void + public var resetPassword: @Sendable (User.ID, User.ResetPassword) async throws -> Void public var token: @Sendable (User.ID) async throws -> User.Token public var update: @Sendable (User.ID, User.Update) async throws -> User } diff --git a/Sources/DatabaseClientLive/Users+live.swift b/Sources/DatabaseClientLive/Users+live.swift index b354c24..583b2a9 100644 --- a/Sources/DatabaseClientLive/Users+live.swift +++ b/Sources/DatabaseClientLive/Users+live.swift @@ -57,6 +57,18 @@ public extension DatabaseClient.Users { guard let token = try await UserTokenModel.find(id, on: database) else { return } try await token.delete(on: database) + } resetPassword: { id, request in + database.logger.debug("Reset password: \(id)") + + try request.validate() + + guard let user = try await UserModel.find(id, on: database) else { + throw Abort(.badRequest, reason: "User not found.") + } + + user.passwordHash = try User.hashPassword(request.password) + try await user.save(on: database) + } token: { _ in guard let user = try await UserModel.query(on: database) .with(\.$token) @@ -134,11 +146,19 @@ extension User.Token { } } +extension User { + + static func hashPassword(_ password: String) throws -> String { + try Bcrypt.hash(password, cost: 12) + } + +} + extension User.Create { func toModel() throws -> UserModel { try validate() - return try .init(username: username, email: email, passwordHash: Bcrypt.hash(password, cost: 12)) + return try .init(username: username, email: email, passwordHash: User.hashPassword(password)) } func validate() throws { @@ -166,6 +186,18 @@ extension User.Login { } } +extension User.ResetPassword { + + func validate() throws { + guard password.count > 8 else { + throw ValidationError(message: "Password should be more than 8 characters long.") + } + guard password == confirmPassword else { + throw ValidationError(message: "Passwords do not match.") + } + } +} + /// The user database model. /// /// A user is someone who is able to login and generate PO's for employees. Generally a user should also @@ -228,6 +260,9 @@ final class UserModel: Model, @unchecked Sendable { ) } + func verifyPassword(_ password: String) throws -> Bool { + try Bcrypt.verify(password, created: passwordHash) + } } final class UserTokenModel: Model, Codable, @unchecked Sendable { @@ -273,7 +308,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator { guard let user = try await UserModel.query(on: request.db) .filter(\UserModel.$username == basic.username) .first(), - try Bcrypt.verify(basic.password, created: user.passwordHash) + try user.verifyPassword(basic.password) else { throw Abort(.unauthorized) } diff --git a/Sources/SharedModels/Routes/ApiRoute.swift b/Sources/SharedModels/Routes/ApiRoute.swift index 7e1230f..998936d 100644 --- a/Sources/SharedModels/Routes/ApiRoute.swift +++ b/Sources/SharedModels/Routes/ApiRoute.swift @@ -121,6 +121,7 @@ public extension SiteRoute { case create(User.Create) case get(id: User.ID) case index + case resetPassword(id: User.ID, request: User.ResetPassword) case update(id: User.ID, updates: User.Update) static let rootPath = "users" @@ -143,6 +144,11 @@ public extension SiteRoute { Path { rootPath } Method.get } + Route(.case(Self.resetPassword(id:request:))) { + Path { rootPath; User.ID.parser(); "reset-password" } + Method.patch + Body(.json(User.ResetPassword.self)) + } Route(.case(Self.update(id:updates:))) { Path { rootPath; User.ID.parser() } Method.patch diff --git a/Sources/SharedModels/User.swift b/Sources/SharedModels/User.swift index b732d76..bb418ad 100644 --- a/Sources/SharedModels/User.swift +++ b/Sources/SharedModels/User.swift @@ -61,6 +61,19 @@ public extension User { } } + struct ResetPassword: Codable, Equatable, Sendable { + public let password: String + public let confirmPassword: String + + public init( + password: String, + confirmPassword: String + ) { + self.password = password + self.confirmPassword = confirmPassword + } + } + struct Token: Codable, Equatable, Identifiable, Sendable { public let id: UUID public let userID: User.ID