Skip to content

Commit 855cd81

Browse files
authored
Add default implementation for CredentialsAuthenticatable (#711)
* Remove warning in tests * Add ModelCredentialsAuthenticatable * Add failing test for credentials stuff * Use the correct user in tests * Get the tests passing * Use provided Database ID for sessions authenticator * Refactor test DB name
1 parent eae4082 commit 855cd81

File tree

4 files changed

+154
-2
lines changed

4 files changed

+154
-2
lines changed

Sources/Fluent/Fluent+Sessions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ private struct DatabaseSessionAuthenticator<User>: SessionAuthenticator
9999
let databaseID: DatabaseID?
100100

101101
func authenticate(sessionID: User.SessionID, for request: Request) -> EventLoopFuture<Void> {
102-
User.find(sessionID, on: request.db).map {
102+
User.find(sessionID, on: request.db(self.databaseID)).map {
103103
if let user = $0 {
104104
request.auth.login(user)
105105
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import Vapor
2+
3+
public protocol ModelCredentialsAuthenticatable: Model, Authenticatable {
4+
static var usernameKey: KeyPath<Self, Field<String>> { get }
5+
static var passwordHashKey: KeyPath<Self, Field<String>> { get }
6+
func verify(password: String) throws -> Bool
7+
}
8+
9+
extension ModelCredentialsAuthenticatable {
10+
public static func credentialsAuthenticator(
11+
database: DatabaseID? = nil
12+
) -> Authenticator {
13+
ModelCredentialsAuthenticator<Self>(database: database)
14+
}
15+
16+
var _$username: Field<String> {
17+
self[keyPath: Self.usernameKey]
18+
}
19+
20+
var _$passwordHash: Field<String> {
21+
self[keyPath: Self.passwordHashKey]
22+
}
23+
}
24+
25+
public struct ModelCredentials: Content {
26+
public let username: String
27+
public let password: String
28+
29+
public init(username: String, password: String) {
30+
self.username = username
31+
self.password = password
32+
}
33+
}
34+
35+
private struct ModelCredentialsAuthenticator<User>: CredentialsAuthenticator
36+
where User: ModelCredentialsAuthenticatable
37+
{
38+
typealias Credentials = ModelCredentials
39+
40+
public let database: DatabaseID?
41+
42+
func authenticate(credentials: ModelCredentials, for request: Request) -> EventLoopFuture<Void> {
43+
User.query(on: request.db(self.database)).filter(\._$username == credentials.username).first().flatMapThrowing { foundUser in
44+
guard let user = foundUser else {
45+
return
46+
}
47+
guard try user.verify(password: credentials.password) else {
48+
return
49+
}
50+
request.auth.login(user)
51+
}
52+
}
53+
}
54+
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import XCTFluent
2+
import XCTVapor
3+
import Fluent
4+
import Vapor
5+
6+
final class CredentialTests: XCTestCase {
7+
8+
func testCredentialsAuthentication() throws {
9+
let app = Application(.testing)
10+
defer { app.shutdown() }
11+
12+
// Setup test db.
13+
let testDB = ArrayTestDatabase()
14+
app.databases.use(testDB.configuration, as: .test)
15+
16+
// Configure sessions.
17+
app.middleware.use(app.sessions.middleware)
18+
19+
// Setup routes.
20+
let sessionRoutes = app.grouped(CredentialsUser.sessionAuthenticator())
21+
22+
let credentialRoutes = sessionRoutes.grouped(CredentialsUser.credentialsAuthenticator())
23+
credentialRoutes.post("login") { req -> Response in
24+
guard req.auth.has(CredentialsUser.self) else {
25+
throw Abort(.unauthorized)
26+
}
27+
return req.redirect(to: "/protected")
28+
}
29+
30+
let protectedRoutes = sessionRoutes.grouped(CredentialsUser.redirectMiddleware(path: "/login"))
31+
protectedRoutes.get("protected") { req -> HTTPStatus in
32+
_ = try req.auth.require(CredentialsUser.self)
33+
return .ok
34+
}
35+
36+
// Create user
37+
let password = "password-\(Int.random())"
38+
let passwordHash = try Bcrypt.hash(password)
39+
let testUser = CredentialsUser(id: UUID(), username: "user-\(Int.random())", password: passwordHash)
40+
testDB.append([TestOutput(testUser)])
41+
testDB.append([TestOutput(testUser)])
42+
testDB.append([TestOutput(testUser)])
43+
testDB.append([TestOutput(testUser)])
44+
45+
// Test login
46+
let loginData = ModelCredentials(username: testUser.username, password: password)
47+
try app.test(.POST, "/login", beforeRequest: { req in
48+
try req.content.encode(loginData, as: .urlEncodedForm)
49+
}) { res in
50+
XCTAssertEqual(res.status, .seeOther)
51+
XCTAssertEqual(res.headers[.location].first, "/protected")
52+
let sessionID = try XCTUnwrap(res.headers.setCookie?["vapor-session"]?.string)
53+
54+
// Test accessing protected route
55+
try app.test(.GET, "/protected", beforeRequest: { req in
56+
var cookies = HTTPCookies()
57+
cookies["vapor-session"] = .init(string: sessionID)
58+
req.headers.cookie = cookies
59+
}) { res in
60+
XCTAssertEqual(res.status, .ok)
61+
}
62+
}
63+
64+
65+
}
66+
}
67+
68+
final class CredentialsUser: Model {
69+
static let schema = "users"
70+
71+
@ID(key: .id)
72+
var id: UUID?
73+
74+
@Field(key: "username")
75+
var username: String
76+
77+
@Field(key: "password")
78+
var password: String
79+
80+
init() { }
81+
82+
init(id: UUID? = nil, username: String, password: String) {
83+
self.id = id
84+
self.username = username
85+
self.password = password
86+
}
87+
}
88+
89+
90+
extension CredentialsUser: ModelCredentialsAuthenticatable {
91+
static let usernameKey = \CredentialsUser.$username
92+
static let passwordHashKey = \CredentialsUser.$password
93+
94+
func verify(password: String) throws -> Bool {
95+
try Bcrypt.verify(password, created: self.password)
96+
}
97+
}
98+
extension CredentialsUser: ModelSessionAuthenticatable {}

Tests/FluentTests/SessionTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ final class SessionTests: XCTestCase {
4848
TestOutput([
4949
"id": UUID(),
5050
"key": SessionID(string: sessionID!),
51-
"data": SessionData(["name": "vapor"])
51+
"data": SessionData(initialData: ["name": "vapor"])
5252
])
5353
])
5454
// Add empty query output for session update.

0 commit comments

Comments
 (0)