Search code examples
swiftalamofirevapor

Problems with token authentication - not authorized


I am following the book 'Server Side Swift with Vapor' and I am at chapter 18. I followed the instructions on how to generate a token when the user logs in. I created a Token class:

import Vapor
import Fluent

final class Token: Model, Content {
    static let schema = "tokens"
    
    @ID
    var id: UUID?
    
    @Field(key: "value")
    var value: String
    
    @Parent(key: "userID")
    var user: User
    
    init() {}
    
    init(id: UUID? = nil, value: String, userID: User.IDValue) {
        self.id = id
        self.value = value
        self.$user.id = userID
    }
}

extension Token {
    static func generate(for user: User) throws -> Token {
        let random = [UInt8].random(count: 16).base64
        return try Token(value: random, userID: user.requireID())
    }
}

extension Token: ModelTokenAuthenticatable {
    typealias User = App.User
    static let valueKey = \Token.$value
    static let userKey = \Token.$user
    
    var isValid: Bool {
        true
    }
}

And this is the Token migration:

import Fluent

struct CreateToken: Migration {
    func prepare(on database: Database) -> EventLoopFuture<Void> {
        database.schema("tokens")
            .id()
            .field("value", .string, .required)
            .field("userID",
                .uuid,
                .required,
                .references("users", "id", onDelete: .cascade))
            .create()
    }
    
    func revert(on database: Database) -> EventLoopFuture<Void> {
        database.schema("tokens").delete()
    }
}

And this is my login route:

func loginHandler(_ req: Request) throws -> EventLoopFuture<Token> {
    let user = try req.auth.require(User.self)
    let token = try Token.generate(for: user)
    
    return token.save(on: req.db).map {
        token
    }
}

And this is how I configure the login route:

let basicAuthGroup = usersRoutes.grouped(basicAuthMiddleware)
basicAuthGroup.post("login", use: loginHandler)

I also have sign up route that is not protected and can be used without being authenticated. What I miss is the step before logging in. Indeed Vapor throws an exception if the user is not authenticated. In alamofire, this is the login method:

func login(user: User) async throws -> AuthToken {
    return try await withCheckedThrowingContinuation { continuation in
        Task {
            let headers = HTTPHeaders([
                HTTPHeader(name: "Authorization", value: "Basic \(user.token!)")
            ])
            
            AF.request(baseUrl + "login", method: .post, parameters: user, headers: headers)
                .responseData { response in
                switch response.result {
                case .success(let data):
                    do {
                        let token = try JSONDecoder().decode(AuthToken.self, from: data)
                        continuation.resume(returning: token)
                    } catch {
                        continuation.resume(throwing: error)
                    }
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
}

The method fails I can see from the server that this is the failing line of code:

let user = try req.auth.require(User.self)

I can see from the book that he does this in two steps:

  1. He obtains a basic auth token
  2. He uses the token to authenticate in the login method

The steps are shown below:

enter image description here

enter image description here

How to do this in Alamofire? I tried using the authenticate() method like this:

func authenticate(user: User) async throws {
    return try await withCheckedThrowingContinuation { continuation in
        Task {
            AF.request(baseUrl, method: .get).authenticate(username: user.username, password: user.password!)
                .responseString { response in
                    switch response.result {
                    case .success(let json):
                        print(json)
                        continuation.resume()
                    case .failure(let error):
                        continuation.resume(throwing: error)
                    }
            }
        }
    }
}

But if I print the json data, it doesn't return any authentication token. It just returns a list of users and I think that the default users GET route gets called instead. I am probably missing something or one step is missing in the book. How to obtain the basic auth token?


Solution

  • You've made your login route expect a token (with your login route function), but when you send the basic authentication (username/password) it doesn't know how to use that information to look up a valid user (since it's expecting a token).

    There are a few steps here that are necessary. I think you've done most of em but here's a full guide from the Vapor Docs:

    First you need a User model in the database:

    import Fluent
    import Vapor
    
    final class User: Model, Content {
        static let schema = "users"
    
        @ID(key: .id)
        var id: UUID?
    
        @Field(key: "name")
        var name: String
    
        @Field(key: "email")
        var email: String
    
        @Field(key: "password_hash")
        var passwordHash: String
    
        init() { }
    
        init(id: UUID? = nil, name: String, email: String, passwordHash: String) {
            self.id = id
            self.name = name
            self.email = email
            self.passwordHash = passwordHash
        }
    }
    

    And a migration:

    import Fluent
    import Vapor
    
    extension User {
        struct Migration: AsyncMigration {
            var name: String { "CreateUser" }
    
            func prepare(on database: Database) async throws {
                try await database.schema("users")
                    .id()
                    .field("name", .string, .required)
                    .field("email", .string, .required)
                    .field("password_hash", .string, .required)
                    .unique(on: "email")
                    .create()
            }
    
            func revert(on database: Database) async throws {
                try await database.schema("users").delete()
            }
        }
    }
    

    In your main file:

    app.migrations.add(User.Migration())
    

    Then what you want is a /signup route (that is open), and takes a username and password (and anything else you need to create a user in the database):

    import Vapor
    
    extension User {
        struct Create: Content {
            var name: String
            var email: String
            var password: String
            var confirmPassword: String
        }
    }
    
    extension User.Create: Validatable {
        static func validations(_ validations: inout Validations) {
            validations.add("name", as: String.self, is: !.empty)
            validations.add("email", as: String.self, is: .email)
            validations.add("password", as: String.self, is: .count(8...))
        }
    }
    

    Sign up, in your routes (POST /users/):

    app.post("users") { req async throws -> User in
        try User.Create.validate(content: req)
        let create = try req.content.decode(User.Create.self)
        guard create.password == create.confirmPassword else {
            throw Abort(.badRequest, reason: "Passwords did not match")
        }
        let user = try User(
            name: create.name,
            email: create.email,
            passwordHash: Bcrypt.hash(create.password)
        )
        try await user.save(on: req.db)
        return user
    }
    

    Then you need to create a /login route that takes a valid username and password (via a ModelAuthenticatable extension on your User model).

    import Fluent
    import Vapor
    
    extension User: ModelAuthenticatable {
        static let usernameKey = \User.$email
        static let passwordHashKey = \User.$passwordHash
    
        func verify(password: String) throws -> Bool {
            try Bcrypt.verify(password, created: self.passwordHash)
        }
    }
    

    This is where the magic happens, and where your code differs:

    let passwordProtected = app.grouped(User.authenticator())
    passwordProtected.post("login") { req -> User in
        try req.auth.require(User.self)
    }
    

    THEN you can create a UserToken model and return a token string on login:

    import Fluent
    import Vapor
    
    final class UserToken: Model, Content {
        static let schema = "user_tokens"
    
        @ID(key: .id)
        var id: UUID?
    
        @Field(key: "value")
        var value: String
    
        @Parent(key: "user_id")
        var user: User
    
        init() { }
    
        init(id: UUID? = nil, value: String, userID: User.IDValue) {
            self.id = id
            self.value = value
            self.$user.id = userID
        }
    }
    
    extension UserToken {
        struct Migration: AsyncMigration {
            var name: String { "CreateUserToken" }
    
            func prepare(on database: Database) async throws {
                try await database.schema("user_tokens")
                    .id()
                    .field("value", .string, .required)
                    .field("user_id", .uuid, .required, .references("users", "id"))
                    .unique(on: "value")
                    .create()
            }
    
            func revert(on database: Database) async throws {
                try await database.schema("user_tokens").delete()
            }
        }
    }
    
    extension User {
        func generateToken() throws -> UserToken {
            try .init(
                value: [UInt8].random(count: 16).base64, 
                userID: self.requireID()
            )
        }
    }
    

    Now modify the login route to return the token:

    let passwordProtected = app.grouped(User.authenticator())
    passwordProtected.post("login") { req async throws -> UserToken in
        let user = try req.auth.require(User.self)
        let token = try user.generateToken()
        try await token.save(on: req.db)
        return token
    }
    

    This is where you would send the Alamofire .authenticate request with the username and password. Expect a token returned.

    AND THEN you can FINALLY authenticate your OTHER routes using the token with a ModelTokenAuthenticatable conforming object.

    import Vapor
    import Fluent
    
    extension UserToken: ModelTokenAuthenticatable {
        static let valueKey = \UserToken.$value
        static let userKey = \UserToken.$user
    
        var isValid: Bool {
            true
        }
    }
    

    And for the FINALE: Make a new route that authenticates using your token you got from the /login route.

    let tokenProtected = app.grouped(UserToken.authenticator())
    tokenProtected.get("me") { req -> User in
        try req.auth.require(User.self)
    }
    

    Use this route by adding a Bearer token header with the token you got from the login route. I like to store the token in the device's keychain for future reference (until the token expires). You can even sync it with iCloud and everything should magically stay logged in across devices!

    Check out an encrypted chat app I made with this pattern using Vapor/SwiftUI/Alamofire: Affirmate