본문으로 건너뛰기

인증(Authentication)#

인증(Authentication)이란 사용자의 신원을 확인하는 행위입니다. 이는 사용자 이름과 비밀번호, 또는 고유한 토큰과 같은 자격 증명(credentials)을 검증함으로써 이루어집니다. 인증(때로는 auth/c라고도 불립니다)은 이미 인증된 사용자가 특정 작업을 수행할 권한이 있는지 확인하는 인가(authorization, auth/z)와는 구분되는 개념입니다.

소개#

Vapor의 인증 API는 BasicBearer를 사용하여 Authorization 헤더를 통해 사용자를 인증하는 기능을 제공합니다. 또한 Content API를 통해 디코딩된 데이터로 사용자를 인증하는 것도 지원합니다.

인증은 검증 로직을 포함하는 Authenticator를 생성함으로써 구현됩니다. Authenticator는 개별 라우트 그룹이나 앱 전체를 보호하는 데 사용할 수 있습니다. Vapor에는 다음과 같은 Authenticator 헬퍼가 기본으로 제공됩니다.

프로토콜 설명
RequestAuthenticator/AsyncRequestAuthenticator 미들웨어를 생성할 수 있는 기본 Authenticator입니다.
BasicAuthenticator/AsyncBasicAuthenticator Basic 인증 헤더를 인증합니다.
BearerAuthenticator/AsyncBearerAuthenticator Bearer 인증 헤더를 인증합니다.
CredentialsAuthenticator/AsyncCredentialsAuthenticator 요청 본문에 담긴 자격 증명 페이로드를 인증합니다.

인증에 성공하면, Authenticator는 검증된 사용자를 req.auth에 추가합니다. 이렇게 추가된 사용자는 해당 Authenticator로 보호되는 라우트에서 req.auth.get(_:)을 사용해 접근할 수 있습니다. 인증에 실패하면 사용자는 req.auth에 추가되지 않으며, 이를 접근하려는 모든 시도는 실패하게 됩니다.

Authenticatable#

인증 API를 사용하려면, 먼저 Authenticatable을 준수하는 사용자 타입이 필요합니다. 이는 struct, class, 심지어 Fluent Model일 수도 있습니다. 아래의 예제들은 name이라는 하나의 프로퍼티를 가진 다음의 간단한 User 구조체를 사용한다고 가정합니다.

import Vapor

struct User: Authenticatable {
    var name: String
}

아래의 각 예제는 우리가 만든 Authenticator의 인스턴스를 사용합니다. 이 예제들에서는 이를 UserAuthenticator라고 부르겠습니다.

라우트#

Authenticator는 미들웨어이며 라우트를 보호하는 데 사용될 수 있습니다.

let protected = app.grouped(UserAuthenticator())
protected.get("me") { req -> String in
    try req.auth.require(User.self).name
}

req.auth.require는 인증된 User를 가져오는 데 사용됩니다. 인증에 실패하면, 이 메서드는 에러를 던져 라우트를 보호합니다.

Guard 미들웨어#

라우트 그룹에 GuardMiddleware를 사용하여, 라우트 핸들러에 도달하기 전에 사용자가 인증되었는지 확인할 수도 있습니다.

let protected = app.grouped(UserAuthenticator())
    .grouped(User.guardMiddleware())

Authenticator를 조합(compose)할 수 있도록, 인증을 요구하는 작업은 Authenticator 미들웨어에서 수행하지 않습니다. 아래의 조합(composition) 항목에서 더 자세히 알아보세요.

Basic#

Basic 인증은 사용자 이름과 비밀번호를 Authorization 헤더에 담아 전송합니다. 사용자 이름과 비밀번호는 콜론으로 연결되고(예: test:secret), base-64로 인코딩된 후 "Basic "이 접두사로 붙습니다. 아래 예제 요청은 사용자 이름 test와 비밀번호 secret을 인코딩합니다.

GET /me HTTP/1.1
Authorization: Basic dGVzdDpzZWNyZXQ=

Basic 인증은 일반적으로 사용자를 한 번 로그인시키고 토큰을 생성하는 데 사용됩니다. 이렇게 하면 사용자의 민감한 비밀번호가 전송되는 빈도를 최소화할 수 있습니다. Basic 인증 정보는 평문(plaintext) 연결이나 검증되지 않은 TLS 연결을 통해 절대로 전송해서는 안 됩니다.

앱에 Basic 인증을 구현하려면, BasicAuthenticator를 준수하는 새로운 Authenticator를 만드세요. 아래는 위의 요청을 검증하도록 하드코딩된 예제 Authenticator입니다.

import Vapor

struct UserAuthenticator: BasicAuthenticator {
    typealias User = App.User

    func authenticate(
        basic: BasicAuthorization,
        for request: Request
    ) -> EventLoopFuture<Void> {
        if basic.username == "test" && basic.password == "secret" {
            request.auth.login(User(name: "Vapor"))
        }
        return request.eventLoop.makeSucceededFuture(())
   }
}

async/await를 사용하고 있다면, 대신 AsyncBasicAuthenticator를 사용할 수 있습니다.

import Vapor

struct UserAuthenticator: AsyncBasicAuthenticator {
    typealias User = App.User

    func authenticate(
        basic: BasicAuthorization,
        for request: Request
    ) async throws {
        if basic.username == "test" && basic.password == "secret" {
            request.auth.login(User(name: "Vapor"))
        }
   }
}

이 프로토콜은 authenticate(basic:for:)를 구현하도록 요구하며, 이 메서드는 들어오는 요청에 Authorization: Basic ... 헤더가 포함되어 있을 때 호출됩니다. 사용자 이름과 비밀번호를 담은 BasicAuthorization 구조체가 이 메서드로 전달됩니다.

이 테스트용 Authenticator에서는 사용자 이름과 비밀번호를 하드코딩된 값과 비교하여 검사합니다. 실제 Authenticator에서는 데이터베이스나 외부 API와 대조하여 확인할 수도 있습니다. 이것이 authenticate 메서드가 future를 반환할 수 있도록 되어 있는 이유입니다.

Tip

비밀번호는 절대로 평문 상태로 데이터베이스에 저장해서는 안 됩니다. 비교에는 항상 비밀번호 해시를 사용하세요.

인증 매개변수가 올바르면, 즉 이 경우에는 하드코딩된 값과 일치하면, Vapor라는 이름의 User가 로그인됩니다. 인증 매개변수가 일치하지 않으면 어떤 사용자도 로그인되지 않으며, 이는 인증 실패를 의미합니다.

앱에 이 Authenticator를 추가하고 위에서 정의한 라우트를 테스트해 보면, 로그인에 성공했을 때 이름 "Vapor"가 반환되는 것을 볼 수 있습니다. 자격 증명이 올바르지 않으면 401 Unauthorized 에러가 나타날 것입니다.

Bearer#

Bearer 인증은 토큰을 Authorization 헤더에 담아 전송합니다. 토큰에는 "Bearer "가 접두사로 붙습니다. 아래 예제 요청은 토큰 foo를 전송합니다.

GET /me HTTP/1.1
Authorization: Bearer foo

Bearer 인증은 흔히 API 엔드포인트를 인증하는 데 사용됩니다. 사용자는 일반적으로 사용자 이름과 비밀번호 같은 자격 증명을 로그인 엔드포인트로 전송하여 Bearer 토큰을 요청합니다. 이 토큰은 애플리케이션의 필요에 따라 몇 분에서 며칠까지 유효할 수 있습니다.

토큰이 유효한 동안에는, 사용자는 API에 인증할 때 자신의 자격 증명 대신 이 토큰을 사용할 수 있습니다. 토큰이 무효화되면, 로그인 엔드포인트를 사용하여 새 토큰을 생성할 수 있습니다.

앱에 Bearer 인증을 구현하려면, BearerAuthenticator를 준수하는 새로운 Authenticator를 만드세요. 아래는 위의 요청을 검증하도록 하드코딩된 예제 Authenticator입니다.

import Vapor

struct UserAuthenticator: BearerAuthenticator {
    typealias User = App.User

    func authenticate(
        bearer: BearerAuthorization,
        for request: Request
    ) -> EventLoopFuture<Void> {
       if bearer.token == "foo" {
           request.auth.login(User(name: "Vapor"))
       }
       return request.eventLoop.makeSucceededFuture(())
   }
}

async/await를 사용하고 있다면, 대신 AsyncBearerAuthenticator를 사용할 수 있습니다.

import Vapor

struct UserAuthenticator: AsyncBearerAuthenticator {
    typealias User = App.User

    func authenticate(
        bearer: BearerAuthorization,
        for request: Request
    ) async throws {
       if bearer.token == "foo" {
           request.auth.login(User(name: "Vapor"))
       }
   }
}

이 프로토콜은 authenticate(bearer:for:)를 구현하도록 요구하며, 이 메서드는 들어오는 요청에 Authorization: Bearer ... 헤더가 포함되어 있을 때 호출됩니다. 토큰을 담은 BearerAuthorization 구조체가 이 메서드로 전달됩니다.

이 테스트용 Authenticator에서는 토큰을 하드코딩된 값과 비교하여 검사합니다. 실제 Authenticator에서는 데이터베이스와 대조하거나, JWT에서처럼 암호화 기법을 사용하여 토큰을 검증할 수도 있습니다. 이것이 authenticate 메서드가 future를 반환할 수 있도록 되어 있는 이유입니다.

Tip

토큰 검증을 구현할 때는 수평적 확장성(horizontal scalability)을 고려하는 것이 중요합니다. 애플리케이션이 많은 사용자를 동시에 처리해야 한다면, 인증이 잠재적인 병목 지점이 될 수 있습니다. 애플리케이션의 여러 인스턴스가 동시에 실행될 때 여러분의 설계가 어떻게 확장될지 고려하세요.

인증 매개변수가 올바르면, 즉 이 경우에는 하드코딩된 값과 일치하면, Vapor라는 이름의 User가 로그인됩니다. 인증 매개변수가 일치하지 않으면 어떤 사용자도 로그인되지 않으며, 이는 인증 실패를 의미합니다.

앱에 이 Authenticator를 추가하고 위에서 정의한 라우트를 테스트해 보면, 로그인에 성공했을 때 이름 "Vapor"가 반환되는 것을 볼 수 있습니다. 자격 증명이 올바르지 않으면 401 Unauthorized 에러가 나타날 것입니다.

조합(Composition)#

여러 개의 Authenticator를 조합(compose)하여, 즉 함께 결합하여 더 복잡한 엔드포인트 인증을 만들 수 있습니다. Authenticator 미들웨어는 인증에 실패하더라도 요청을 거부하지 않기 때문에, 이러한 미들웨어를 두 개 이상 연결(chain)할 수 있습니다. Authenticator는 두 가지 주요 방식으로 조합할 수 있습니다.

메서드 조합#

인증을 조합하는 첫 번째 방법은 동일한 사용자 타입에 대해 둘 이상의 Authenticator를 연결하는 것입니다. 다음 예제를 살펴보세요.

app.grouped(UserPasswordAuthenticator())
    .grouped(UserTokenAuthenticator())
    .grouped(User.guardMiddleware())
    .post("login") 
{ req in
    let user = try req.auth.require(User.self)
    // user로 무언가를 수행합니다.
}

이 예제는 둘 다 User를 인증하는 UserPasswordAuthenticatorUserTokenAuthenticator라는 두 개의 Authenticator가 있다고 가정합니다. 이 두 Authenticator는 모두 라우트 그룹에 추가됩니다. 마지막으로, User가 성공적으로 인증되었음을 요구하기 위해 Authenticator들 뒤에 GuardMiddleware가 추가됩니다.

이렇게 Authenticator를 조합하면 비밀번호나 토큰 중 어느 쪽으로든 접근할 수 있는 라우트가 만들어집니다. 이러한 라우트를 사용하면 사용자가 로그인하여 토큰을 생성한 다음, 계속해서 그 토큰으로 새로운 토큰을 생성하도록 할 수 있습니다.

사용자 조합#

인증을 조합하는 두 번째 방법은 서로 다른 사용자 타입에 대한 Authenticator를 연결하는 것입니다. 다음 예제를 살펴보세요.

app.grouped(AdminAuthenticator())
    .grouped(UserAuthenticator())
    .get("secure") 
{ req in
    guard req.auth.has(Admin.self) || req.auth.has(User.self) else {
        throw Abort(.unauthorized)
    }
    // 작업을 수행합니다.
}

이 예제는 각각 AdminUser를 인증하는 AdminAuthenticatorUserAuthenticator라는 두 개의 Authenticator가 있다고 가정합니다. 이 두 Authenticator는 모두 라우트 그룹에 추가됩니다. GuardMiddleware를 사용하는 대신, Admin 또는 User 중 하나라도 인증되었는지 확인하는 검사가 라우트 핸들러에 추가됩니다. 인증되지 않았다면 에러가 던져집니다.

이렇게 Authenticator를 조합하면, 서로 다른 인증 방식을 가질 수 있는 두 가지 다른 유형의 사용자가 접근할 수 있는 라우트가 만들어집니다. 이러한 라우트를 사용하면 일반 사용자 인증을 유지하면서도 슈퍼 유저에게 접근 권한을 부여할 수 있습니다.

수동(Manual)#

req.auth를 사용하여 인증을 수동으로 처리할 수도 있습니다. 이는 특히 테스트에 유용합니다.

사용자를 수동으로 로그인시키려면 req.auth.login(_:)을 사용하세요. Authenticatable한 사용자라면 무엇이든 이 메서드에 전달할 수 있습니다.

req.auth.login(User(name: "Vapor"))

인증된 사용자를 가져오려면 req.auth.require(_:)를 사용하세요.

let user: User = try req.auth.require(User.self)
print(user.name) // String

인증이 실패했을 때 자동으로 에러를 던지지 않길 원한다면, req.auth.get(_:)을 사용할 수도 있습니다.

let user = req.auth.get(User.self)
print(user?.name) // String?

사용자의 인증을 해제하려면, req.auth.logout(_:)에 사용자 타입을 전달하세요.

req.auth.logout(User.self)

Fluent#

Fluent는 기존 모델에 추가할 수 있는 두 개의 프로토콜 ModelAuthenticatableModelTokenAuthenticatable을 정의합니다. 모델을 이 프로토콜에 준수시키면 엔드포인트를 보호하는 Authenticator를 생성할 수 있습니다.

ModelTokenAuthenticatable은 Bearer 토큰으로 인증합니다. 이것이 대부분의 엔드포인트를 보호하는 데 사용하는 방법입니다. ModelAuthenticatable은 사용자 이름과 비밀번호로 인증하며, 토큰을 생성하는 단일 엔드포인트에서 사용됩니다.

이 가이드는 여러분이 Fluent에 익숙하며 데이터베이스를 사용하도록 앱을 성공적으로 설정했다고 가정합니다. Fluent를 처음 사용한다면, 개요부터 시작하세요.

User#

시작하려면, 인증될 사용자를 나타내는 모델이 필요합니다. 이 가이드에서는 다음 모델을 사용하겠지만, 이미 있는 모델을 자유롭게 사용해도 됩니다.

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
    }
}

이 모델은 사용자 이름(여기서는 이메일)과 비밀번호 해시를 저장할 수 있어야 합니다. 또한 중복 사용자를 방지하기 위해 email을 고유 필드로 설정합니다. 이 예제 모델에 대응하는 마이그레이션은 다음과 같습니다.

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()
        }
    }
}

app.migrations에 마이그레이션을 추가하는 것을 잊지 마세요.

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

Tip

이메일 주소는 대소문자를 구분하지 않으므로, 데이터베이스에 저장하기 전에 이메일 주소를 소문자로 변환하는 Middleware를 추가하고 싶을 수 있습니다. 다만, ModelAuthenticatable은 대소문자를 구분하는 비교를 사용하므로, 이렇게 한다면 클라이언트 측에서 대소문자를 변환하거나 사용자 정의 Authenticator를 사용하는 등의 방법으로 사용자의 입력이 모두 소문자인지 확인해야 합니다.

가장 먼저 필요한 것은 새로운 사용자를 생성하는 엔드포인트입니다. POST /users를 사용하겠습니다. 이 엔드포인트가 기대하는 데이터를 나타내는 Content 구조체를 만드세요.

import Vapor

extension User {
    struct Create: Content {
        var name: String
        var email: String
        var password: String
        var confirmPassword: String
    }
}

원한다면, 이 구조체를 Validatable에 준수시켜 검증 요구사항을 추가할 수 있습니다.

import Vapor

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...))
    }
}

이제 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
}

이 엔드포인트는 들어오는 요청을 검증하고, User.Create 구조체를 디코딩한 다음, 비밀번호가 일치하는지 확인합니다. 그런 다음 디코딩된 데이터를 사용하여 새로운 User를 생성하고 데이터베이스에 저장합니다. 평문 비밀번호는 데이터베이스에 저장되기 전에 Bcrypt로 해시됩니다.

프로젝트를 빌드하고 실행하세요. 이때 먼저 데이터베이스 마이그레이션을 반드시 실행해야 하며, 그런 다음 다음 요청을 사용하여 새로운 사용자를 생성하세요.

POST /users HTTP/1.1
Content-Length: 97
Content-Type: application/json

{
    "name": "Vapor",
    "email": "test@vapor.codes",
    "password": "secret42",
    "confirmPassword": "secret42"
}

Model Authenticatable#

이제 사용자 모델과 새로운 사용자를 생성하는 엔드포인트가 있으니, 모델을 ModelAuthenticatable에 준수시켜 봅시다. 이렇게 하면 사용자 이름과 비밀번호로 모델을 인증할 수 있게 됩니다.

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)
    }
}

이 익스텐션은 UserModelAuthenticatable 준수성을 추가합니다. 처음 두 프로퍼티는 각각 사용자 이름과 비밀번호 해시를 저장하는 데 사용할 필드를 지정합니다. \ 표기법은 Fluent가 해당 필드에 접근하는 데 사용할 수 있는 키 경로(key path)를 생성합니다.

마지막 요구사항은 Basic 인증 헤더로 전송된 평문 비밀번호를 검증하는 메서드입니다. 회원가입 시 비밀번호를 해시하는 데 Bcrypt를 사용했으므로, 전달받은 비밀번호가 저장된 비밀번호 해시와 일치하는지 검증하는 데도 Bcrypt를 사용하겠습니다.

이제 UserModelAuthenticatable을 준수하므로, 로그인 라우트를 보호하는 Authenticator를 만들 수 있습니다.

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

ModelAuthenticatable은 Authenticator를 생성하는 정적(static) 메서드 authenticator를 추가합니다.

다음 요청을 전송하여 이 라우트가 동작하는지 테스트해 보세요.

POST /login HTTP/1.1
Authorization: Basic dGVzdEB2YXBvci5jb2RlczpzZWNyZXQ0Mg==

이 요청은 Basic 인증 헤더를 통해 사용자 이름 test@vapor.codes와 비밀번호 secret42를 전달합니다. 앞서 생성한 사용자가 반환되는 것을 볼 수 있을 것입니다.

이론적으로는 Basic 인증을 사용하여 모든 엔드포인트를 보호할 수도 있지만, 대신 별도의 토큰을 사용하는 것이 권장됩니다. 이렇게 하면 사용자의 민감한 비밀번호를 인터넷을 통해 전송하는 빈도를 최소화할 수 있습니다. 또한 로그인 시에만 비밀번호 해싱을 수행하면 되므로 인증 속도도 훨씬 빨라집니다.

User Token#

사용자 토큰을 나타내는 새로운 모델을 만드세요.

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
    }
}

이 모델에는 토큰의 고유 문자열을 저장하는 value 필드가 있어야 합니다. 또한 사용자 모델에 대한 parent 관계도 있어야 합니다. 필요에 따라 만료 날짜와 같은 추가 프로퍼티를 이 토큰에 추가할 수도 있습니다.

다음으로, 이 모델에 대한 마이그레이션을 만드세요.

import Fluent

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()
        }
    }
}

이 마이그레이션은 value 필드를 고유하게 만듭니다. 또한 user_id 필드와 users 테이블 사이에 외래 키(foreign key) 참조도 생성합니다.

app.migrations에 마이그레이션을 추가하는 것을 잊지 마세요.

app.migrations.add(UserToken.Migration())

마지막으로, 새로운 토큰을 생성하기 위한 메서드를 User에 추가하세요. 이 메서드는 로그인 시에 사용됩니다.

extension User {
    func generateToken() throws -> UserToken {
        try .init(
            value: [UInt8].random(count: 16).base64, 
            userID: self.requireID()
        )
    }
}

여기서는 무작위 토큰 값을 생성하기 위해 [UInt8].random(count:)을 사용하고 있습니다. 이 예제에서는 16바이트, 즉 128비트의 무작위 데이터가 사용됩니다. 이 숫자는 필요에 따라 조정할 수 있습니다. 무작위 데이터는 HTTP 헤더로 쉽게 전송할 수 있도록 base-64로 인코딩됩니다.

이제 사용자 토큰을 생성할 수 있으니, POST /login 라우트를 업데이트하여 토큰을 생성하고 반환하도록 합니다.

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
}

위와 동일한 로그인 요청을 사용하여 이 라우트가 동작하는지 테스트해 보세요. 이제 로그인하면 다음과 같은 형태의 토큰을 얻게 될 것입니다.

8gtg300Jwdhc/Ffw784EXA==

곧 사용할 것이므로 이 토큰을 잘 보관해 두세요.

Model Token Authenticatable#

UserTokenModelTokenAuthenticatable에 준수시키세요. 이렇게 하면 토큰으로 User 모델을 인증할 수 있게 됩니다.

import Vapor
import Fluent

extension UserToken: ModelTokenAuthenticatable {
    static var valueKey: KeyPath<UserToken, Field<String>> { \.$value }
    static var userKey: KeyPath<UserToken, Parent<User>> { \.$user }

    var isValid: Bool {
        true
    }
}

첫 번째 프로토콜 요구사항은 토큰의 고유 값을 저장하는 필드를 지정합니다. 이 값이 바로 Bearer 인증 헤더로 전송될 값입니다. 두 번째 요구사항은 User 모델에 대한 parent 관계를 지정합니다. 이를 통해 Fluent가 인증된 사용자를 조회하게 됩니다.

마지막 요구사항은 isValid라는 불리언 값입니다. 이 값이 false이면, 토큰은 데이터베이스에서 삭제되고 사용자는 인증되지 않습니다. 여기서는 간단히 하기 위해 이 값을 true로 하드코딩하여 토큰이 영구히 유효하도록 만들겠습니다.

이제 토큰이 ModelTokenAuthenticatable을 준수하니, 라우트를 보호하는 Authenticator를 만들 수 있습니다.

현재 인증된 사용자를 가져오는 새로운 엔드포인트 GET /me를 만드세요.

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

User와 마찬가지로, UserToken도 이제 Authenticator를 생성할 수 있는 정적 authenticator() 메서드를 가지고 있습니다. 이 Authenticator는 Bearer 인증 헤더에 제공된 값과 일치하는 UserToken을 찾으려고 시도합니다. 일치하는 값을 찾으면, 관련된 User를 가져와 인증합니다.

POST /login 요청에서 저장해 둔 값을 토큰으로 사용하여 다음 HTTP 요청을 전송해 보고, 이 라우트가 동작하는지 테스트해 보세요.

GET /me HTTP/1.1
Authorization: Bearer <token>

인증된 User가 반환되는 것을 볼 수 있을 것입니다.

세션(Session)#

Vapor의 세션 API를 사용하면 요청 간에 사용자 인증을 자동으로 유지할 수 있습니다. 이는 로그인에 성공한 후 요청의 세션 데이터에 사용자의 고유 식별자를 저장하는 방식으로 동작합니다. 이후 요청에서는 세션에서 사용자의 식별자를 가져와, 라우트 핸들러를 호출하기 전에 사용자를 인증하는 데 사용합니다.

세션은 HTML을 웹 브라우저에 직접 제공하는, Vapor로 만든 프런트엔드 웹 애플리케이션에 매우 유용합니다. API의 경우, 요청 간에 사용자 데이터를 유지하기 위해 상태를 갖지 않는(stateless) 토큰 기반 인증을 사용하는 것을 권장합니다.

Session Authenticatable#

세션 기반 인증을 사용하려면, SessionAuthenticatable을 준수하는 타입이 필요합니다. 이 예제에서는 간단한 구조체를 사용하겠습니다.

import Vapor

struct User {
    var email: String
}

SessionAuthenticatable을 준수하려면, sessionID를 지정해야 합니다. 이는 세션 데이터에 저장될 값이며, 반드시 사용자를 고유하게 식별할 수 있어야 합니다.

extension User: SessionAuthenticatable {
    var sessionID: String {
        self.email
    }
}

간단한 User 타입에서는, 고유한 세션 식별자로 이메일 주소를 사용하겠습니다.

Session Authenticator#

다음으로, 유지된 세션 식별자로부터 User 인스턴스를 알아내는 작업을 처리할 SessionAuthenticator가 필요합니다.

struct UserSessionAuthenticator: SessionAuthenticator {
    typealias User = App.User
    func authenticate(sessionID: String, for request: Request) -> EventLoopFuture<Void> {
        let user = User(email: sessionID)
        request.auth.login(user)
        return request.eventLoop.makeSucceededFuture(())
    }
}

async/await를 사용하고 있다면, AsyncSessionAuthenticator를 사용할 수 있습니다.

struct UserSessionAuthenticator: AsyncSessionAuthenticator {
    typealias User = App.User
    func authenticate(sessionID: String, for request: Request) async throws {
        let user = User(email: sessionID)
        request.auth.login(user)
    }
}

예제로 든 User를 초기화하는 데 필요한 모든 정보가 세션 식별자에 담겨 있으므로, 사용자를 동기적으로 생성하고 로그인시킬 수 있습니다. 실제 애플리케이션에서는, 사용자를 인증하기 전에 세션 식별자를 사용하여 데이터베이스 조회나 API 요청을 수행하여 나머지 사용자 데이터를 가져오는 경우가 많을 것입니다.

다음으로, 초기 인증을 수행할 간단한 bearer authenticator를 만들어 보겠습니다.

struct UserBearerAuthenticator: AsyncBearerAuthenticator {
    func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
        if bearer.token == "test" {
            let user = User(email: "hello@vapor.codes")
            request.auth.login(user)
        }
    }
}

이 Authenticator는 bearer 토큰 test가 전송되면 이메일 hello@vapor.codes를 가진 사용자를 인증합니다.

마지막으로, 이 모든 부분들을 애플리케이션에서 하나로 결합해 보겠습니다.

// 사용자 인증을 요구하는 보호된 라우트 그룹을 만듭니다.
let protected = app.routes.grouped([
    app.sessions.middleware,
    UserSessionAuthenticator(),
    UserBearerAuthenticator(),
    User.guardMiddleware(),
])

// 사용자의 이메일을 읽기 위한 GET /me 라우트를 추가합니다.
protected.get("me") { req -> String in
    try req.auth.require(User.self).email
}

먼저 애플리케이션에서 세션 지원을 활성화하기 위해 SessionsMiddleware가 추가됩니다. 세션을 설정하는 방법에 대한 더 자세한 정보는 세션 API 섹션에서 확인할 수 있습니다.

다음으로, SessionAuthenticator가 추가됩니다. 이는 세션이 활성 상태일 경우 사용자를 인증하는 역할을 담당합니다.

세션에 아직 인증 정보가 저장되어 있지 않다면, 요청은 다음 Authenticator로 전달됩니다. UserBearerAuthenticator는 bearer 토큰을 확인하여, 그 값이 "test"와 같으면 사용자를 인증합니다.

마지막으로, User.guardMiddleware()는 앞선 미들웨어들 중 하나가 User를 인증했는지 확인합니다. 사용자가 인증되지 않았다면 에러가 던져집니다.

이 라우트를 테스트하려면, 먼저 다음 요청을 전송하세요.

GET /me HTTP/1.1
authorization: Bearer test

이렇게 하면 UserBearerAuthenticator가 사용자를 인증하게 됩니다. 인증되고 나면, UserSessionAuthenticator가 사용자의 식별자를 세션 저장소에 저장하고 쿠키를 생성합니다. 응답에서 받은 쿠키를 사용하여 이 라우트에 두 번째 요청을 보내세요.

GET /me HTTP/1.1
cookie: vapor_session=123

이번에는 UserSessionAuthenticator가 사용자를 인증하며, 다시 한번 사용자의 이메일이 반환되는 것을 볼 수 있을 것입니다.

Model Session Authenticatable#

Fluent 모델은 ModelSessionAuthenticatable을 준수시킴으로써 SessionAuthenticator를 생성할 수 있습니다. 이렇게 하면 모델의 고유 식별자를 세션 식별자로 사용하고, 세션으로부터 모델을 복원하기 위한 데이터베이스 조회를 자동으로 수행합니다.

import Fluent

final class User: Model { ... }

// 이 모델이 세션에 저장될 수 있도록 허용합니다.
extension User: ModelSessionAuthenticatable { }

기존의 어떤 모델에든 빈 준수(empty conformance)로 ModelSessionAuthenticatable을 추가할 수 있습니다. 추가하고 나면, 해당 모델에 대한 SessionAuthenticator를 생성하는 새로운 정적 메서드를 사용할 수 있게 됩니다.

User.sessionAuthenticator()

이렇게 하면 사용자를 조회하는 데 애플리케이션의 기본 데이터베이스가 사용됩니다. 특정 데이터베이스를 지정하려면, 식별자를 전달하세요.

User.sessionAuthenticator(.sqlite)

웹사이트 인증#

웹사이트는 인증에 있어 특별한 경우인데, 브라우저를 사용한다는 특성상 브라우저에 자격 증명을 첨부하는 방식에 제약이 있기 때문입니다. 이로 인해 두 가지 서로 다른 인증 시나리오가 생겨납니다.

  • 폼(form)을 통한 초기 로그인

  • 세션 쿠키로 인증되는 이후의 호출들

Vapor와 Fluent는 이를 매끄럽게 만들어주는 여러 헬퍼를 제공합니다.

세션 인증#

세션 인증은 앞서 설명한 대로 동작합니다. 사용자가 접근하게 될 모든 라우트에 세션 미들웨어와 세션 Authenticator를 적용해야 합니다. 여기에는 모든 보호된 라우트, 공개되어 있지만 사용자가 로그인되어 있다면 그 정보에 접근하고 싶을 수 있는 라우트(예를 들어 계정 버튼을 표시하기 위해), 그리고 로그인 라우트가 포함됩니다.

configure.swift에서 다음과 같이 앱 전체에 이를 전역적으로 활성화할 수 있습니다.

app.middleware.use(app.sessions.middleware)
app.middleware.use(User.sessionAuthenticator())

이 미들웨어들은 다음과 같은 작업을 수행합니다.

  • 세션 미들웨어는 요청에 제공된 세션 쿠키를 가져와 이를 세션으로 변환합니다

  • 세션 Authenticator는 세션을 가져와 해당 세션에 인증된 사용자가 있는지 확인합니다. 있다면, 미들웨어는 요청을 인증합니다. 응답에서는, 세션 Authenticator가 요청에 인증된 사용자가 있는지 확인하고, 다음 요청에서도 인증되도록 그 사용자를 세션에 저장합니다.

Note

세션 쿠키는 기본적으로 securehttpOnly로 설정되지 않습니다. 쿠키를 구성하는 방법에 대한 더 자세한 정보는 Vapor의 세션 API를 확인하세요.

라우트 보호하기#

API를 위해 라우트를 보호할 때는, 전통적으로 요청이 인증되지 않은 경우 401 Unauthorized와 같은 상태 코드를 가진 HTTP 응답을 반환합니다. 그러나 이는 브라우저를 사용하는 사람에게는 그리 좋은 사용자 경험이 아닙니다. Vapor는 이러한 시나리오에서 사용할 수 있도록, 모든 Authenticatable 타입에 대한 RedirectMiddleware를 제공합니다.

let protectedRoutes = app.grouped(User.redirectMiddleware(path: "/login?loginRequired=true"))

고급 URL 처리를 위해, RedirectMiddleware 객체는 생성 시 리다이렉트 경로를 String으로 반환하는 클로저를 전달하는 것도 지원합니다. 예를 들어, 상태 관리를 위해 리다이렉트 대상 경로에 리다이렉트 출발지 경로를 쿼리 매개변수로 포함시킬 수 있습니다.

let redirectMiddleware = User.redirectMiddleware { req -> String in
  return "/login?authRequired=true&next=\(req.url.path)"
}

이는 GuardMiddleware와 유사하게 동작합니다. protectedRoutes에 등록된 라우트로의 요청 중 인증되지 않은 것은 모두 제공된 경로로 리다이렉트됩니다. 이를 통해 단순히 401 Unauthorized를 제공하는 대신, 사용자에게 로그인하도록 안내할 수 있습니다.

RedirectMiddleware가 실행되기 전에 인증된 사용자가 로드되도록, RedirectMiddleware 앞에 Session Authenticator를 반드시 포함시키세요.

let protectedRoutes = app.grouped([User.sessionAuthenticator(), redirectMiddleware])

폼 로그인#

세션으로 사용자와 이후의 요청들을 인증하려면, 사용자를 로그인시켜야 합니다. Vapor는 준수할 수 있는 ModelCredentialsAuthenticatable 프로토콜을 제공합니다. 이는 폼을 통한 로그인을 처리합니다. 먼저 User를 이 프로토콜에 준수시키세요.

extension User: ModelCredentialsAuthenticatable {
    static let usernameKey = \User.$email
    static let passwordHashKey = \User.$password

    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.password)
    }
}

이는 ModelAuthenticatable과 동일하며, 이미 이를 준수하고 있다면 별도로 할 작업이 없습니다. 다음으로, 로그인 폼의 POST 요청에 ModelCredentialsAuthenticator 미들웨어를 적용하세요.

let credentialsProtectedRoute = sessionRoutes.grouped(User.credentialsAuthenticator())
credentialsProtectedRoute.post("login", use: loginPostHandler)

이는 기본 credentials Authenticator를 사용하여 로그인 라우트를 보호합니다. POST 요청에는 usernamepassword를 반드시 전송해야 합니다. 폼은 다음과 같이 구성할 수 있습니다.

 <form method="POST" action="/login">
    <label for="username">Username</label>
    <input type="text" id="username" placeholder="Username" name="username" autocomplete="username" required autofocus>
    <label for="password">Password</label>
    <input type="password" id="password" placeholder="Password" name="password" autocomplete="current-password" required>
    <input type="submit" value="Sign In">    
</form>

CredentialsAuthenticator는 요청 본문에서 usernamepassword를 추출하고, 사용자 이름으로 사용자를 찾아 비밀번호를 검증합니다. 비밀번호가 유효하면, 미들웨어는 요청을 인증합니다. 이후 SessionAuthenticator가 다음 요청들을 위해 세션을 인증합니다.

JWT#

JWT는 들어오는 요청에서 JSON Web Token을 인증하는 데 사용할 수 있는 JWTAuthenticator를 제공합니다. JWT를 처음 사용한다면, 개요를 확인하세요.

먼저, JWT 페이로드를 나타내는 타입을 만드세요.

// 예제 JWT 페이로드.
struct SessionToken: Content, Authenticatable, JWTPayload {

    // 상수
    let expirationTime: TimeInterval = 60 * 15
    
    // 토큰 데이터
    var expiration: ExpirationClaim
    var userId: UUID
    
    init(userId: UUID) {
        self.userId = userId
        self.expiration = ExpirationClaim(value: Date().addingTimeInterval(expirationTime))
    }
    
    init(with user: User) throws {
        self.userId = try user.requireID()
        self.expiration = ExpirationClaim(value: Date().addingTimeInterval(expirationTime))
    }

    func verify(using algorithm: some JWTAlgorithm) throws {
        try expiration.verifyNotExpired()
    }
}

다음으로, 성공적인 로그인 응답에 담길 데이터를 나타내는 표현을 정의할 수 있습니다. 지금은 응답이 서명된 JWT를 나타내는 문자열 프로퍼티 하나만 가지도록 하겠습니다.

struct ClientTokenResponse: Content {
    var token: String
}

JWT 토큰과 응답을 위한 모델을 사용하여, ClientTokenResponse를 반환하고 서명된 SessionToken을 포함하는 비밀번호 보호 로그인 라우트를 만들 수 있습니다.

let passwordProtected = app.grouped(User.authenticator(), User.guardMiddleware())
passwordProtected.post("login") { req async throws -> ClientTokenResponse in
    let user = try req.auth.require(User.self)
    let payload = try SessionToken(with: user)
    return ClientTokenResponse(token: try await req.jwt.sign(payload))
}

또는, Authenticator를 사용하고 싶지 않다면 다음과 같은 형태를 사용할 수도 있습니다.

app.post("login") { req async throws -> ClientTokenResponse in
    // 사용자에 대해 제공된 자격 증명을 검증합니다
    // 제공된 사용자의 userId를 가져옵니다
    let payload = try SessionToken(userId: userId)
    return ClientTokenResponse(token: try await req.jwt.sign(payload))
}

페이로드를 AuthenticatableJWTPayload에 준수시킴으로써, authenticator() 메서드를 사용하여 라우트 Authenticator를 생성할 수 있습니다. 라우트가 호출되기 전에 JWT를 자동으로 가져와 검증하도록 라우트 그룹에 이를 추가하세요.

// SessionToken JWT를 요구하는 라우트 그룹을 만듭니다.
let secure = app.grouped(SessionToken.authenticator(), SessionToken.guardMiddleware())

선택적인 guard 미들웨어를 추가하면 인가(authorization)가 성공했음을 요구하게 됩니다.

보호된 라우트 내부에서는, req.auth를 사용하여 인증된 JWT 페이로드에 접근할 수 있습니다.

// 사용자가 제공한 토큰이 유효하면 ok 응답을 반환합니다.
secure.post("validateLoggedInUser") { req -> HTTPStatus in
    let sessionToken = try req.auth.require(SessionToken.self)
    print(sessionToken.userId)
    return .ok
}
이 페이지 편집