feat: Adds reset password api route, needs associated view route.

This commit is contained in:
2025-01-26 21:03:10 -05:00
parent 1f2bb900ca
commit d4a2048b12
5 changed files with 60 additions and 2 deletions

View File

@@ -110,6 +110,9 @@ private extension SiteRoute.Api.UserRoute {
return user return user
// case let .login(user): // case let .login(user):
// return try await users.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): case let .update(id: id, updates: updates):
return try await users.update(id, updates) return try await users.update(id, updates)
} }

View File

@@ -15,6 +15,7 @@ public extension DatabaseClient {
public var get: @Sendable (User.ID) async throws -> User? public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void 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 token: @Sendable (User.ID) async throws -> User.Token
public var update: @Sendable (User.ID, User.Update) async throws -> User public var update: @Sendable (User.ID, User.Update) async throws -> User
} }

View File

@@ -57,6 +57,18 @@ public extension DatabaseClient.Users {
guard let token = try await UserTokenModel.find(id, on: database) guard let token = try await UserTokenModel.find(id, on: database)
else { return } else { return }
try await token.delete(on: database) 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 } token: { _ in
guard let user = try await UserModel.query(on: database) guard let user = try await UserModel.query(on: database)
.with(\.$token) .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 { extension User.Create {
func toModel() throws -> UserModel { func toModel() throws -> UserModel {
try validate() 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 { 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. /// The user database model.
/// ///
/// A user is someone who is able to login and generate PO's for employees. Generally a user should also /// 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 { 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) guard let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == basic.username) .filter(\UserModel.$username == basic.username)
.first(), .first(),
try Bcrypt.verify(basic.password, created: user.passwordHash) try user.verifyPassword(basic.password)
else { else {
throw Abort(.unauthorized) throw Abort(.unauthorized)
} }

View File

@@ -121,6 +121,7 @@ public extension SiteRoute {
case create(User.Create) case create(User.Create)
case get(id: User.ID) case get(id: User.ID)
case index case index
case resetPassword(id: User.ID, request: User.ResetPassword)
case update(id: User.ID, updates: User.Update) case update(id: User.ID, updates: User.Update)
static let rootPath = "users" static let rootPath = "users"
@@ -143,6 +144,11 @@ public extension SiteRoute {
Path { rootPath } Path { rootPath }
Method.get 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:))) { Route(.case(Self.update(id:updates:))) {
Path { rootPath; User.ID.parser() } Path { rootPath; User.ID.parser() }
Method.patch Method.patch

View File

@@ -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 { struct Token: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
public let userID: User.ID public let userID: User.ID