├── .codebeatignore
├── .codecov.yml
├── .gitignore
├── Tests
├── LinuxMain.swift
└── ResetTests
│ └── ResetTests.swift
├── .swiftlint.yml
├── Resources
└── Views
│ └── Reset
│ ├── Password
│ ├── reset-password-success.leaf
│ ├── reset-password-request-success.leaf
│ ├── reset-password-request-form.leaf
│ └── reset-password-form.leaf
│ └── Layout
│ └── base.leaf
├── Sources
└── Reset
│ ├── PasswordChangeCountClaim.swift
│ ├── ResetError.swift
│ ├── Config
│ ├── ResetEndpoints.swift
│ ├── ResetConfig.swift
│ └── ResetResponses.swift
│ ├── Tags
│ └── ResetConfigTag.swift
│ ├── Provider
│ └── ResetProvider.swift
│ ├── Command
│ └── GeneratePasswordResetTokenCommand.swift
│ ├── PasswordResettable.swift
│ └── Controllers
│ └── ResetController.swift
├── Public
└── Reset
│ └── css
│ └── reset.css
├── .github
└── workflows
│ ├── documentation.yml
│ └── test.yml
├── LICENSE
├── Package.swift
└── README.md
/.codebeatignore:
--------------------------------------------------------------------------------
1 | Public/**
2 | Resources/Assets/**
--------------------------------------------------------------------------------
/.codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | range: "0...100"
3 | ignore:
4 | - "Tests"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | Packages
2 | .build
3 | .idea
4 | .DS_Store
5 | *.xcodeproj
6 | DerivedData/
7 | Package.resolved
8 | .swiftpm
9 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ResetTests
3 |
4 | XCTMain([
5 | testCase(ResetTests.allTests),
6 | ])
7 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | included:
2 | - Sources
3 | function_body_length:
4 | warning: 60
5 | identifier_name:
6 | min_length:
7 | warning: 2
8 | line_length: 100
9 | disabled_rules:
10 | - opening_brace
11 | colon:
12 | flexible_right_spacing: true
13 |
--------------------------------------------------------------------------------
/Resources/Views/Reset/Password/reset-password-success.leaf:
--------------------------------------------------------------------------------
1 | #set("pageTitle") { Password updated }
2 |
3 | #set("body") {
4 |
5 |
#reset:config("name")
6 |
Reset password
7 |
Your password has been updated.
8 |
9 | }
10 |
11 | #embed("Reset/Layout/base")
12 |
--------------------------------------------------------------------------------
/Resources/Views/Reset/Password/reset-password-request-success.leaf:
--------------------------------------------------------------------------------
1 | #set("pageTitle") { Link sent }
2 |
3 | #set("body") {
4 |
5 |
#reset:config("name")
6 |
Reset password
7 |
We have sent you a link to reset your password.
8 |
9 | }
10 |
11 | #embed("Reset/Layout/base")
12 |
--------------------------------------------------------------------------------
/Tests/ResetTests/ResetTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import HTTP
3 | @testable import Reset
4 |
5 | class ResetTests: XCTestCase {
6 | func testExample() throws {
7 | XCTAssertTrue(true)
8 | }
9 |
10 | static var allTests : [(String, (ResetTests) -> () throws -> Void)] {
11 | return [
12 | ("testExample", testExample),
13 | ]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/Reset/PasswordChangeCountClaim.swift:
--------------------------------------------------------------------------------
1 | import JWT
2 |
3 | /// Identifies the number of times a user's password has been changed
4 | /// - id: pcc
5 | public struct PasswordChangeCountClaim: JWTClaim {
6 | /// The number of times the password has been changed
7 | public var value: Int
8 |
9 | /// See `Claim`
10 | public init(value: Int) {
11 | self.value = value
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Public/Reset/css/reset.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | height: 100%;
4 | }
5 |
6 | body {
7 | display: -ms-flexbox;
8 | display: flex;
9 | -ms-flex-align: center;
10 | align-items: center;
11 | padding-top: 40px;
12 | padding-bottom: 40px;
13 | background-color: #f5f5f5;
14 | }
15 |
16 | .reset-password {
17 | width: 100%;
18 | max-width: 500px;
19 | padding: 15px;
20 | margin: auto;
21 | }
22 | .reset-password .form-control {
23 | position: relative;
24 | box-sizing: border-box;
25 | height: auto;
26 | padding: 10px;
27 | font-size: 16px;
28 | }
29 | .reset-password .form-control:focus {
30 | z-index: 2;
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/documentation.yml:
--------------------------------------------------------------------------------
1 | name: Documentation
2 |
3 | on: [release]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v1
11 | - name: Generate Documentation
12 | uses: SwiftDocOrg/swift-doc@master
13 | with:
14 | inputs: "Sources/Reset"
15 | module-name: Reset
16 | output: "Documentation"
17 | - name: Upload Documentation to Wiki
18 | uses: SwiftDocOrg/github-wiki-publish-action@master@v1
19 | with:
20 | path: "Documentation"
21 | env:
22 | GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.WIKI_ACCESS_TOKEN }}
--------------------------------------------------------------------------------
/Resources/Views/Reset/Password/reset-password-request-form.leaf:
--------------------------------------------------------------------------------
1 | #set("pageTitle") { Request password reset }
2 |
3 | #set("body") {
4 |
18 | }
19 |
20 | #embed("Reset/Layout/base")
21 |
--------------------------------------------------------------------------------
/Resources/Views/Reset/Layout/base.leaf:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | #get(pageTitle)
11 |
12 |
13 |
14 |
15 |
16 |
17 | #get(styles)
18 |
19 |
20 |
21 | #get(body)
22 | #get(javascript)
23 |
24 |
--------------------------------------------------------------------------------
/Sources/Reset/ResetError.swift:
--------------------------------------------------------------------------------
1 | import Vapor
2 |
3 | public enum ResetError: Error {
4 | case tokenAlreadyUsed
5 | case userNotFound
6 | }
7 |
8 | // MARK: - AbortError
9 | extension ResetError: AbortError {
10 | public var status: HTTPResponseStatus {
11 | switch self {
12 | case .tokenAlreadyUsed : return .forbidden
13 | case .userNotFound : return .notFound
14 | }
15 | }
16 |
17 | public var reason: String {
18 | switch self {
19 | case .tokenAlreadyUsed : return "A password reset token can only be used once."
20 | case .userNotFound : return "Could not find user."
21 | }
22 | }
23 | }
24 |
25 | // MARK: - Debuggable
26 | extension ResetError: Debuggable {
27 | public var identifier: String {
28 | switch self {
29 | case .tokenAlreadyUsed : return "tokenAlreadyUsed"
30 | case .userNotFound : return "userNotFound"
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - master
7 | jobs:
8 | macos:
9 | runs-on: macos-latest
10 | env:
11 | DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer
12 | steps:
13 | - uses: actions/checkout@v2
14 | - run: brew install libressl
15 | - run: xcrun swift test --enable-test-discovery --sanitize=thread -Xcxx "-I/usr/local/opt/openssl/include" -Xlinker "-L/usr/local/opt/openssl/lib"
16 | linux-4_2:
17 | runs-on: ubuntu-latest
18 | container:
19 | image: swift:4.2
20 | steps:
21 | - uses: actions/checkout@v2
22 | - run: swift test
23 | linux:
24 | runs-on: ubuntu-latest
25 | strategy:
26 | fail-fast: false
27 | matrix:
28 | image:
29 | - swift:5.2-focal
30 | - swift:5.3-focal
31 | container: ${{ matrix.image }}
32 | steps:
33 | - uses: actions/checkout@v2
34 | - run: apt-get -qq update && apt-get install -y libssl-dev
35 | - run: swift test --enable-test-discovery --sanitize=thread
36 |
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Nodes
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:4.1
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "Reset",
6 | products: [
7 | .library(name: "Reset", targets: ["Reset"]),
8 | ],
9 | dependencies: [
10 | .package(url: "https://github.com/nodes-vapor/submissions.git", from: "2.0.0"),
11 | .package(url: "https://github.com/nodes-vapor/sugar.git", from: "4.0.0"),
12 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.0"),
13 | .package(url: "https://github.com/vapor/fluent.git", from: "3.0.0"),
14 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"),
15 | .package(url: "https://github.com/vapor/leaf.git", from: "3.0.0"),
16 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0")
17 | ],
18 | targets: [
19 | .target(name: "Reset", dependencies: [
20 | "Authentication",
21 | "Fluent",
22 | "JWT",
23 | "Leaf",
24 | "Sugar",
25 | "Submissions",
26 | "Vapor"
27 | ]),
28 | .testTarget(name: "ResetTests", dependencies: ["Reset"]),
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/Sources/Reset/Config/ResetEndpoints.swift:
--------------------------------------------------------------------------------
1 | import Routing
2 | import Vapor
3 |
4 | public struct ResetEndpoints {
5 | public let renderResetPasswordRequest: String?
6 | public let resetPasswordRequest: String?
7 | public let renderResetPassword: String?
8 | public let resetPassword: String?
9 |
10 | /// Endpoints to use by provider when registering routes.
11 | ///
12 | /// - Parameters:
13 | /// - renderResetPasswordRequest: reset password request form endpoint (GET).
14 | /// - resetPasswordRequest: reset password request endpoint (POST).
15 | /// - renderResetPassword: reset password form endpoint (GET).
16 | /// - resetPassword: reset password endpoint (POST).
17 | public init(
18 | renderResetPasswordRequest: String? = "/users/reset-password/request",
19 | resetPasswordRequest: String? = "/users/reset-password/request",
20 | renderResetPassword: String? = "/users/reset-password",
21 | resetPassword: String? = "/users/reset-password"
22 | ) {
23 | self.renderResetPasswordRequest = renderResetPasswordRequest
24 | self.resetPasswordRequest = resetPasswordRequest
25 | self.renderResetPassword = renderResetPassword
26 | self.resetPassword = resetPassword
27 | }
28 |
29 | public static var `default`: ResetEndpoints {
30 | return .init()
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Reset/Tags/ResetConfigTag.swift:
--------------------------------------------------------------------------------
1 | import Async
2 | import Leaf
3 | import TemplateKit
4 |
5 | public final class ResetConfigTag: TagRenderer {
6 | public func render(tag: TagContext) throws -> Future {
7 | try tag.requireParameterCount(1)
8 | let config = try tag.container.make(ResetConfigTagData.self)
9 | return try tag.future(config.viewData(for: tag.parameters[0], tag: tag))
10 | }
11 |
12 | public init() {}
13 | }
14 |
15 | public final class ResetConfigTagData: Service {
16 | enum Keys: String {
17 | case name
18 | case baseURL
19 | }
20 |
21 | public let name: String
22 | public let baseURL: String
23 |
24 | init(name: String, baseURL: String) {
25 | self.name = name
26 | self.baseURL = baseURL
27 | }
28 |
29 | func viewData(for data: TemplateData, tag: TagContext) throws -> TemplateData {
30 | guard let key = data.string else {
31 | throw tag.error(reason: "Wrong type given (expected a string): \(type(of: data))")
32 | }
33 |
34 | guard let parsedKey = Keys(rawValue: key) else {
35 | throw tag.error(reason: "Wrong argument given: \(key)")
36 | }
37 |
38 | switch parsedKey {
39 | case .name:
40 | return .string(name)
41 | case .baseURL:
42 | return .string(baseURL)
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Resources/Views/Reset/Password/reset-password-form.leaf:
--------------------------------------------------------------------------------
1 | #set("pageTitle") { Reset your password}
2 |
3 | #set("body") {
4 |
22 | }
23 |
24 | #set("javascript") {
25 |
40 | }
41 |
42 | #embed("Reset/Layout/base")
43 |
--------------------------------------------------------------------------------
/Sources/Reset/Config/ResetConfig.swift:
--------------------------------------------------------------------------------
1 | import JWT
2 | import Service
3 | import Sugar
4 | import Vapor
5 |
6 | public struct ResetConfig: Service {
7 | public let baseURL: String
8 | public let controller: ResetControllerType
9 | public let endpoints: ResetEndpoints
10 | public let name: String
11 | public let responses: ResetResponses
12 | public let signer: JWTSigner
13 |
14 | /// Creates a new PasswordReset configuration.
15 | ///
16 | /// - Parameters:
17 | /// - name: the name of your project.
18 | /// - baseURL: the base url of your project. Used for generating reset password links.
19 | /// - endpoints: determines the endpoints for the routes
20 | /// - signer: signer and expiration period for password reset tokens.
21 | /// - responses: contains the responses to be returned when requesting the endpoints.
22 | /// - controller: contains logic for handling the reset password flow.
23 | public init(
24 | name: String,
25 | baseURL: String,
26 | endpoints: ResetEndpoints = .default,
27 | signer: JWTSigner,
28 | responses: ResetResponses = .default,
29 | controller: ResetControllerType = ResetController()
30 | ) {
31 | self.baseURL = baseURL
32 | self.controller = controller
33 | self.endpoints = endpoints
34 | self.name = name
35 | self.responses = responses
36 | self.signer = signer
37 | }
38 | }
39 |
40 | // MAKR: - Helpers
41 |
42 | public extension ResetConfig {
43 | func reset(
44 | _ object: T,
45 | context: T.Context,
46 | on req: Request
47 | ) throws -> Future {
48 | let expirationPeriod = T.expirationPeriod(for: context)
49 | let expirableSigner = ExpireableJWTSigner(
50 | expirationPeriod: expirationPeriod,
51 | signer: signer
52 | )
53 | return try object
54 | .signToken(using: expirableSigner, on: req)
55 | .flatMap(to: Void.self) { token in
56 | let url = self.baseURL
57 | .appending("\(self.endpoints.renderResetPassword ?? "")/\(token)")
58 | return try object.sendPasswordReset(
59 | url: url,
60 | token: token,
61 | expirationPeriod: expirationPeriod,
62 | context: context,
63 | on: req
64 | )
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/Reset/Provider/ResetProvider.swift:
--------------------------------------------------------------------------------
1 | import Authentication
2 | import Fluent
3 | import JWT
4 | import Leaf
5 | import Sugar
6 | import Vapor
7 |
8 | public final class ResetProvider {
9 | private let configFactory: (Container) throws -> ResetConfig
10 |
11 | public init(configFactory: @escaping (Container) throws -> ResetConfig) {
12 | self.configFactory = configFactory
13 | }
14 | }
15 |
16 | // MARK: - Provider
17 |
18 | extension ResetProvider: Provider {
19 | public func register(_ services: inout Services) throws {
20 | services.register(factory: configFactory)
21 |
22 | services.register { container -> ResetConfigTagData in
23 | let config: ResetConfig = try container.make()
24 | return ResetConfigTagData(name: config.name, baseURL: config.baseURL)
25 | }
26 | }
27 |
28 | public func didBoot(_ container: Container) throws -> Future {
29 | return .done(on: container)
30 | }
31 | }
32 |
33 | // MARK: - Commands
34 |
35 | extension ResetProvider where U.Database: QuerySupporting, U.ID: LosslessStringConvertible {
36 | public static func commands(
37 | databaseIdentifier: DatabaseIdentifier
38 | ) -> [String: Command] {
39 | return [
40 | "reset:generate-token": GeneratePasswordResetTokenCommand(
41 | databaseIdentifier: databaseIdentifier
42 | )
43 | ]
44 | }
45 | }
46 |
47 | // MARK: - Routes
48 |
49 | public extension Router {
50 | func useResetRoutes(
51 | _ type: U.Type,
52 | on container: Container
53 | ) throws {
54 | let config: ResetConfig = try container.make()
55 | let endpoints = config.endpoints
56 | let controller = config.controller
57 |
58 | if let renderResetPasswordRequestPath = endpoints.renderResetPasswordRequest {
59 | get(renderResetPasswordRequestPath) { req in try controller.renderResetPasswordRequestForm(req) }
60 | }
61 |
62 | if let resetPasswordRequestPath = endpoints.resetPasswordRequest {
63 | post(resetPasswordRequestPath) { req in try controller.resetPasswordRequest(req) }
64 | }
65 |
66 | if let renderResetPasswordPath = endpoints.renderResetPassword {
67 | get(renderResetPasswordPath, String.parameter) { req in try controller.renderResetPasswordForm(req) }
68 | }
69 |
70 | if let resetPasswordPath = endpoints.resetPassword {
71 | post(resetPasswordPath, String.parameter) { req in try controller.resetPassword(req) }
72 | }
73 | }
74 | }
75 |
76 | // MARK: Leaf tags
77 |
78 | public extension LeafTagConfig {
79 | mutating func useResetLeafTags() {
80 | use(ResetConfigTag(), as: "reset:config")
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/Sources/Reset/Config/ResetResponses.swift:
--------------------------------------------------------------------------------
1 | import Leaf
2 | import Sugar
3 | import Vapor
4 |
5 | public struct ResetResponses {
6 | private enum ViewPaths: String {
7 | case resetPasswordRequestForm = "Reset/Password/reset-password-request-form"
8 | case resetPasswordUserNotified = "Reset/Password/reset-password-request-success"
9 | case resetPasswordForm = "Reset/Password/reset-password-form"
10 | case resetPasswordSuccess = "Reset/Password/reset-password-success"
11 | }
12 |
13 | public let resetPasswordRequestForm: (Request) throws -> Future
14 | public let resetPasswordUserNotified: (Request) throws -> Future
15 | public let resetPasswordForm: (Request, U) throws -> Future
16 | public let resetPasswordSuccess: (Request, U) throws -> Future
17 |
18 | public init(
19 | resetPasswordRequestForm: @escaping (Request) throws -> Future,
20 | resetPasswordUserNotified: @escaping (Request) throws -> Future,
21 | resetPasswordForm: @escaping (Request, U) throws -> Future,
22 | resetPasswordSuccess: @escaping (Request, U) throws -> Future
23 | ) {
24 | self.resetPasswordRequestForm = resetPasswordRequestForm
25 | self.resetPasswordUserNotified = resetPasswordUserNotified
26 | self.resetPasswordForm = resetPasswordForm
27 | self.resetPasswordSuccess = resetPasswordSuccess
28 | }
29 |
30 | public static var `default`: ResetResponses {
31 | return .init(
32 | resetPasswordRequestForm: { req in
33 | try req
34 | .view()
35 | .render(ViewPaths.resetPasswordRequestForm.rawValue, on: req)
36 | .encode(for: req)
37 | },
38 | resetPasswordUserNotified: { req in
39 | guard
40 | req.http.accept.comparePreference(for: .html, to: .json) == .orderedAscending
41 | else {
42 | return try HTTPResponse(status: .ok).encode(for: req)
43 | }
44 |
45 | return try req
46 | .view()
47 | .render(ViewPaths.resetPasswordUserNotified.rawValue, on: req)
48 | .encode(for: req)
49 | },
50 | resetPasswordForm: { req, user in
51 | try req
52 | .make(LeafRenderer.self)
53 | .render(ViewPaths.resetPasswordForm.rawValue)
54 | .encode(for: req)
55 | },
56 | resetPasswordSuccess: { req, user in
57 | guard
58 | req.http.accept.comparePreference(for: .html, to: .json) == .orderedAscending
59 | else {
60 | return try HTTPResponse(status: .ok).encode(for: req)
61 | }
62 |
63 | return try req
64 | .view()
65 | .render(ViewPaths.resetPasswordSuccess.rawValue, on: req)
66 | .encode(for: req)
67 | }
68 | )
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Sources/Reset/Command/GeneratePasswordResetTokenCommand.swift:
--------------------------------------------------------------------------------
1 | import Authentication
2 | import Command
3 | import Fluent
4 | import Sugar
5 |
6 | /// Generates password reset tokens for a user which can be used to reset their password.
7 | public struct GeneratePasswordResetTokenCommand: Command {
8 |
9 | /// See `Command`
10 | public let arguments: [CommandArgument] = [.argument(name: Keys.query)]
11 |
12 | /// See `CommandRunnable`
13 | public let options: [CommandOption] = []
14 |
15 | /// See `CommandRunnable`
16 | public let help = ["Generates a password reset token for a user with a given id."]
17 |
18 | private let makeFilter: (String) -> FilterOperator
19 | private let databaseIdentifier: DatabaseIdentifier
20 | private let context: U.Context
21 |
22 | /// Creates a new password reset token command with a custom lookup strategy.
23 | ///
24 | /// Example to enable search by email:
25 | /// ```
26 | /// GeneratePasswordResetTokenCommand(databaseIdentifier: .mysql) { query in
27 | /// try \User.email == $0
28 | /// }
29 | /// ```
30 | ///
31 | /// - Parameters:
32 | /// - databaseIdentifier: identifier of database from where to load the user.
33 | /// - makeFilter: used to create the filter from the query.
34 | /// - context: The Reset context to use when generating the token.
35 | public init(
36 | databaseIdentifier: DatabaseIdentifier,
37 | makeFilter: @escaping (String) -> FilterOperator,
38 | context: U.Context = U.Context.requestResetPassword()
39 | ) {
40 | self.databaseIdentifier = databaseIdentifier
41 | self.makeFilter = makeFilter
42 | self.context = context
43 | }
44 |
45 | /// See `CommandRunnable`
46 | public func run(using context: CommandContext) throws -> Future {
47 | let container = context.container
48 | let query = try context.argument(Keys.query)
49 | let config: ResetConfig = try container.make()
50 | let expirationPeriod = U.expirationPeriod(for: self.context)
51 | let expirableSigner = ExpireableJWTSigner(
52 | expirationPeriod: expirationPeriod,
53 | signer: config.signer
54 | )
55 |
56 | return container.withPooledConnection(to: databaseIdentifier) { connection in
57 | U.query(on: connection)
58 | .filter(self.makeFilter(query))
59 | .first()
60 | .unwrap(or: ResetError.userNotFound)
61 | .flatMap(to: String.self) { user in
62 | try user.signToken(using: expirableSigner, on: container)
63 | }
64 | .map {
65 | context.console.print("Password Reset Token: \($0)")
66 | }
67 | }
68 | }
69 | }
70 |
71 | public extension GeneratePasswordResetTokenCommand where U.ID: LosslessStringConvertible {
72 | /// Creates a new password reset token command that looks up users by database identifier.
73 | ///
74 | /// - Parameters:
75 | /// - databaseIdentifier: identifier of database from where to load the user.
76 | /// - context: The Reset context to use when generating the token.
77 | init(
78 | databaseIdentifier: DatabaseIdentifier,
79 | context: U.Context = U.Context.requestResetPassword()
80 | ) {
81 | self.databaseIdentifier = databaseIdentifier
82 | self.makeFilter = { query -> FilterOperator in
83 | U.idKey == U.ID(query)
84 | }
85 | self.context = context
86 | }
87 | }
88 |
89 | private enum Keys {
90 | static let query = "query"
91 | }
92 |
--------------------------------------------------------------------------------
/Sources/Reset/PasswordResettable.swift:
--------------------------------------------------------------------------------
1 | import Authentication
2 | import Fluent
3 | import JWT
4 | import Submissions
5 | import Sugar
6 | import Vapor
7 |
8 | public protocol HasPasswordChangeCount {
9 | var passwordChangeCount: Int { get }
10 | }
11 |
12 | public protocol HasRequestResetPasswordContext {
13 | static func requestResetPassword() -> Self
14 | }
15 |
16 | public enum ResetPasswordContext: HasRequestResetPasswordContext {
17 | case userRequestedToResetPassword
18 |
19 | public static func requestResetPassword() -> ResetPasswordContext {
20 | return .userRequestedToResetPassword
21 | }
22 | }
23 |
24 | public protocol PasswordResettable:
25 | HasPassword,
26 | HasPasswordChangeCount,
27 | JWTAuthenticatable,
28 | Model
29 | where
30 | Self.JWTPayload: HasPasswordChangeCount
31 | {
32 | associatedtype Context: HasRequestResetPasswordContext
33 | associatedtype RequestReset: Creatable
34 | associatedtype ResetPassword: Creatable, HasReadablePassword
35 |
36 | static func find(
37 | by requestLink: RequestReset,
38 | on connection: DatabaseConnectable
39 | ) throws -> Future
40 |
41 | func sendPasswordReset(
42 | url: String,
43 | token: String,
44 | expirationPeriod: TimeInterval,
45 | context: Context,
46 | on req: Request
47 | ) throws -> Future
48 |
49 | /// By incrementing this value on each password change and including it in the JWT payload,
50 | /// this value ensures that a password reset token can only be used once.
51 | var passwordChangeCount: Int { get set }
52 |
53 | static func expirationPeriod(for context: Context) -> TimeInterval
54 | }
55 |
56 | public extension PasswordResettable {
57 | static func expirationPeriod(for context: Context) -> TimeInterval {
58 | return 1.hoursInSecs
59 | }
60 | }
61 |
62 | extension PasswordResettable where
63 | Self: PasswordAuthenticatable,
64 | Self.RequestReset: HasReadableUsername
65 | {
66 | public static func find(
67 | by payload: RequestReset,
68 | on connection: DatabaseConnectable
69 | ) -> Future {
70 | let username = payload[keyPath: RequestReset.readableUsernameKey]
71 | return query(on: connection).filter(Self.usernameKey == username).first()
72 | }
73 | }
74 |
75 | extension PasswordResettable where
76 | Self.JWTPayload: ModelPayloadType,
77 | Self == Self.JWTPayload.PayloadModel
78 | {
79 | public func makePayload(
80 | expirationTime: Date,
81 | on container: Container
82 | ) -> Future {
83 | return Future.map(on: container) {
84 | try Self.JWTPayload(expirationTime: expirationTime, model: self)
85 | }
86 | }
87 | }
88 |
89 | public protocol ModelPayloadType: ExpireableSubjectPayload, HasPasswordChangeCount {
90 | associatedtype PayloadModel: Model
91 | init(expirationTime: Date, model: PayloadModel) throws
92 | }
93 |
94 | public struct ModelPayload:
95 | ModelPayloadType
96 | where
97 | U: HasPasswordChangeCount,
98 | U.ID: LosslessStringConvertible
99 | {
100 | public typealias PayloadModel = U
101 |
102 | public let exp: ExpirationClaim
103 | public let pcc: PasswordChangeCountClaim
104 | public let sub: SubjectClaim
105 |
106 | public init(
107 | expirationTime: Date,
108 | model: U
109 | ) throws {
110 | self.exp = ExpirationClaim(value: expirationTime)
111 | self.pcc = PasswordChangeCountClaim(value: model.passwordChangeCount)
112 | self.sub = try SubjectClaim(value: model.requireID().description)
113 | }
114 |
115 | public var passwordChangeCount: Int {
116 | return pcc.value
117 | }
118 |
119 | public func verify(using signer: JWTSigner) throws {
120 | try exp.verifyNotExpired()
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/Sources/Reset/Controllers/ResetController.swift:
--------------------------------------------------------------------------------
1 | import Authentication
2 | import JWT
3 | import Sugar
4 | import Vapor
5 |
6 | public protocol ResetControllerType {
7 | func renderResetPasswordRequestForm(_ req: Request) throws -> Future
8 | func resetPasswordRequest(_ req: Request) throws -> Future
9 | func renderResetPasswordForm(_ req: Request) throws -> Future
10 | func resetPassword(_ req: Request) throws -> Future
11 | }
12 |
13 | open class ResetController
14 | : ResetControllerType
15 | {
16 | public init() {}
17 |
18 | open func renderResetPasswordRequestForm(_ req: Request) throws -> Future {
19 | let config: ResetConfig = try req.make()
20 | return try config.responses.resetPasswordRequestForm(req)
21 | }
22 |
23 | open func resetPasswordRequest(_ req: Request) throws -> Future {
24 | let config: ResetConfig = try req.make()
25 | return U.RequestReset.create(on: req)
26 | .flatMap(to: U?.self) { try U.find(by: $0, on: req) }
27 | .flatTry { user in
28 | guard let user = user else {
29 | // ignore case where user could not be found to prevent malicious attackers from
30 | // finding out which accounts are available on the system
31 | return .done(on: req)
32 | }
33 | return try config.reset(
34 | user,
35 | context: U.Context.requestResetPassword(),
36 | on: req
37 | )
38 | }
39 | .flatMap(to: Response.self) { _ in
40 | try config.responses.resetPasswordUserNotified(req)
41 | }
42 | }
43 |
44 | open func renderResetPasswordForm(_ req: Request) throws -> Future {
45 | let config: ResetConfig = try req.make()
46 | let payload = try config.extractVerifiedPayload(from: req.parameters.next())
47 |
48 | return try U
49 | .authenticate(using: payload, on: req)
50 | .unwrap(or: ResetError.userNotFound)
51 | .flatMap(to: Response.self) { user in
52 | guard user.passwordChangeCount == payload.passwordChangeCount else {
53 | throw ResetError.tokenAlreadyUsed
54 | }
55 | return try config.responses.resetPasswordForm(req, user)
56 | }
57 | }
58 |
59 | open func resetPassword(_ req: Request) throws -> Future {
60 | let config: ResetConfig = try req.make()
61 | let payload = try config.extractVerifiedPayload(from: req.parameters.next())
62 |
63 | return try U
64 | .authenticate(using: payload, on: req)
65 | .unwrap(or: ResetError.userNotFound)
66 | .try { user in
67 | guard user.passwordChangeCount == payload.passwordChangeCount else {
68 | throw ResetError.tokenAlreadyUsed
69 | }
70 | }
71 | .and(U.ResetPassword.create(on: req))
72 | .flatMap(to: U.self) { user, resetPassword in
73 | var user = user
74 | let password = resetPassword[keyPath: U.ResetPassword.readablePasswordKey]
75 | user[keyPath: U.passwordKey] = try U.hashPassword(password)
76 | user.passwordChangeCount += 1
77 | return user.save(on: req)
78 | }
79 | .flatMap(to: Response.self) { user in
80 | try config.responses.resetPasswordSuccess(req, user)
81 | }
82 | }
83 | }
84 |
85 | public extension ResetConfig {
86 | func extractVerifiedPayload(from token: String) throws -> U.JWTPayload {
87 | let payload = try JWT(from: token, verifiedUsing: signer).payload
88 |
89 | try payload.verify(using: signer)
90 |
91 | return payload
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Reset 🏳
2 | [](http://swift.org)
3 | [](http://vapor.codes)
4 | [](https://circleci.com/gh/nodes-vapor/reset)
5 | [](https://codebeat.co/projects/github-com-nodes-vapor-reset-master)
6 | [](https://codecov.io/gh/nodes-vapor/reset)
7 | [](http://clayallsopp.github.io/readme-score?url=https://github.com/nodes-vapor/reset)
8 | [](https://raw.githubusercontent.com/nodes-vapor/reset/master/LICENSE)
9 |
10 | This package makes it easy to handle flows that involves resetting a password. It's up for the consumer to decide how to distribute the token that allows one to reset the password.
11 |
12 | ## 📦 Installation
13 |
14 | Add `Reset` to the package dependencies (in your `Package.swift` file):
15 | ```swift
16 | dependencies: [
17 | ...,
18 | .package(url: "https://github.com/nodes-vapor/reset.git", from: "1.0.0")
19 | ]
20 | ```
21 |
22 | as well as to your target (e.g. "App"):
23 |
24 | ```swift
25 | targets: [
26 | ...
27 | .target(
28 | name: "App",
29 | dependencies: [... "Reset" ...]
30 | ),
31 | ...
32 | ]
33 | ```
34 |
35 | Next, copy/paste the `Resources/Views/Reset` folder into your project in order to be able to use the provided Leaf files. These files can be changed as explained in the [Specifying the responses](#specifying-the-responses) section, however it's recommended to copy this folder to your project anyway. This makes it easier for you to keep track of updates and your project will work if you decide later on to not use your own customized leaf files.
36 |
37 | ## Getting started 🚀
38 |
39 | First make sure that you've imported Reset everywhere it's needed:
40 |
41 | ```swift
42 | import Reset
43 | ```
44 |
45 | ### Adding the Provider
46 |
47 | Reset comes with a light-weight provider that we'll need to register in the `configure` function in our `configure.swift` file:
48 |
49 | ```swift
50 | try services.register(ResetProvider(config: ResetConfig(
51 | name: AppConfig.app.name,
52 | baseURL: AppConfig.app.url,
53 | signer: ExpireableJWTSigner(
54 | expirationPeriod: 3600, // 1 hour
55 | signer: .hs256(
56 | key: env(EnvironmentKey.Reset.signerKey, "secret-reset"
57 | ).convertToData())
58 | )
59 | ))
60 | )
61 | ```
62 |
63 | Please see [Making a `PasswordResettable` model](#making-a-passwordresettable-model) for more information on confirming a type to `PasswordResettable`.
64 |
65 | ### Adding the Reset routes
66 |
67 | Make sure to add the relevant Reset routes, e.g. in your configure.swift or routes.swift:
68 |
69 | ```swift
70 | services.register(Router.self) { container -> EngineRouter in
71 | let router = EngineRouter.default()
72 | try router.useResetRoutes(User.self, on: container)
73 | return router
74 | }
75 | ```
76 |
77 | ### Adding the Leaf tag
78 |
79 | This package comes with a small Leaf tag that is used to pass Reset-related information such as project name and project url to Leaf. To add it to your project, please do the following:
80 |
81 | ```swift
82 | public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws {
83 | services.register { _ -> LeafTagConfig in
84 | var tags = LeafTagConfig.default()
85 | tags.useResetLeafTags()
86 | return tags
87 | }
88 | }
89 | ```
90 |
91 | ## Making a `PasswordResettable` model
92 |
93 | There's a couple of things that needs to be in place for conforming your model to `PasswordResettable`. The following example is based on having a `User` model which you would like to add support for resetting a password.
94 |
95 | ### Request and reset structs
96 |
97 | The first thing to define is the data that is needed in order to request a reset password flow and the data for actually resetting the password. It could look like this:
98 |
99 | ```swift
100 | extension User: PasswordResettable {
101 | // ...
102 |
103 | public struct RequestReset: RequestCreatable, Decodable, HasReadableUsername {
104 | static let readableUsernameKey = \RequestReset.username
105 | public let username: String
106 | }
107 |
108 | public struct ResetPassword: RequestCreatable, Decodable, HasReadablePassword {
109 | static let readablePasswordKey = \ResetPassword.password
110 | public let password: String
111 | }
112 |
113 | // ..
114 | }
115 | ```
116 |
117 | Basically the username (this could also be the email) is needed to request a reset flow and a new password is needed to submit the password change.
118 |
119 | > Note how that `RequestReset` conforms to `HasReadableUsername`. This enables Reset to implement the `find` method for looking up the user automatically.
120 |
121 | ### Sending the reset-password url
122 |
123 | Once the user has requested to reset their password, the `sendPasswordReset` function will be called. The implementation could send the url by email or just include the token in a text message. It's up to the implementer to decide how to distribute this.
124 |
125 | Here's an example using the [Mailgun](https://github.com/twof/VaporMailgunService) package to send out an email with the reset password url:
126 |
127 | ```swift
128 | extension User: PasswordResettable {
129 | // ...
130 |
131 | public func sendPasswordReset(
132 | url: String,
133 | token: String,
134 | expirationPeriod: TimeInterval,
135 | context: ResetPasswordContext,
136 | on req: Request
137 | ) throws -> Future {
138 | let mailgun = try req.make(Mailgun.self)
139 | let expire = Int(expirationPeriod / 60) // convert to minutes
140 |
141 | return try req
142 | .make(LeafRenderer.self)
143 | .render(ViewPath.Reset.resetPasswordEmail, ["url": url, "expire": expire])
144 | .map(to: String.self) { view in
145 | String(bytes: view.data, encoding: .utf8) ?? ""
146 | }
147 | .map(to: Mailgun.Message.self) { html in
148 | Mailgun.Message(
149 | from: "donotreply@reset.com",
150 | to: self.email,
151 | subject: "Reset password",
152 | text: "Please turn on html to view this email.",
153 | html: html
154 | )
155 | }
156 | .flatMap(to: Response.self) { message in
157 | try mailgun.send(message, on: req)
158 | }
159 | .transform(to: ())
160 | }
161 |
162 | // ..
163 | }
164 | ```
165 |
166 | ### Handling multiple reset flows
167 |
168 | There might be cases where you would want to have multiple signers for multiple different reset password flows. One example could be to handle the regular reset password flow as well as automatically resetting a password when a user gets created. By implementing the `signer` function, you're able to handle this:
169 |
170 | ```swift
171 | extension User: PasswordResettable {
172 | // ...
173 |
174 | public enum MyResetPasswordContext: HasRequestResetPasswordContext {
175 | case userRequestedToResetPassword
176 | case newUserWithoutPassword
177 |
178 | public static func requestResetPassword() -> MyResetPasswordContext {
179 | return .userRequestedToResetPassword
180 | }
181 | }
182 |
183 | public func signer(
184 | for context: MyResetPasswordContext,
185 | on container: Container
186 | ) throws -> ExpireableJWTSigner {
187 | let resetConfig: ResetConfig = try container.make() // The default signer
188 | let myConfig: MyConfig = try container.make() // Some project specific config that holds the extra signer
189 |
190 | switch context {
191 | case .userRequestedToResetPassword: return resetConfig.signer
192 | case .newUserWithoutPassword: return myConfig.newUserSetPasswordSigner
193 | }
194 | }
195 |
196 | // ..
197 | }
198 | ```
199 |
200 | > Please note that you need to implement your own `Context` if you want to handle multiple signers.
201 |
202 | ## Specifying the responses
203 |
204 | All endpoints and responses that Reset uses can be overwritten. Reset provides responses for the following cases:
205 |
206 | - Form for requesting a reset password flow
207 | - Response for letting the user know that the reset password url has been sent
208 | - Form for resetting the password
209 | - Response for letting the user know that the password has been reset
210 |
211 | Here's a small example where the request to reset password should only be exposed through the API:
212 |
213 | ```swift
214 |
215 | let customResponse = ResetResponses(
216 | resetPasswordRequestForm: { req in
217 | return try HTTPResponse(status: .notFound).encode(for: req)
218 | },
219 | resetPasswordUserNotified: { req in
220 | return try HTTPResponse(status: .noContent).encode(for: req)
221 | },
222 | resetPasswordForm: { req, user in
223 | return try req
224 | .make(LeafRenderer.self)
225 | .render("MyPathForShowingResetForm")
226 | .encode(for: req)
227 | },
228 | resetPasswordSuccess: { req, user in
229 | return try req
230 | .make(LeafRenderer.self)
231 | .render("MyPathForShowingResetPasswordSuccess")
232 | .encode(for: req)
233 | }
234 | )
235 |
236 | ```
237 |
238 | This instance can then be used when registering the provider as explained in [Adding the Provider](#adding-the-provider).
239 |
240 | Alternatively, instead of passing in `ResetResponses` in the `ResetConfig`, one could pass in their own implementation of `ResetControllerType` for full customizability.
241 |
242 | ## 🏆 Credits
243 |
244 | This package is developed and maintained by the Vapor team at [Nodes](https://www.nodesagency.com).
245 |
246 |
247 | ## 📄 License
248 |
249 | This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT)
250 |
--------------------------------------------------------------------------------