├── .gitignore ├── Dockerfile ├── Documentation.md ├── Localizations ├── de.json ├── en.json └── fr.json ├── Package.swift ├── Procfile ├── Public └── .gitkeep ├── README.md ├── Sources ├── App │ ├── Configuration │ │ ├── boot.swift │ │ ├── configure.swift │ │ └── router.swift │ ├── Controllers │ │ ├── AdminController.swift │ │ ├── AuthController.swift │ │ └── UserController.swift │ ├── Models │ │ ├── AccessToken.swift │ │ ├── Attribute │ │ │ └── Attribute.swift │ │ ├── User │ │ │ ├── User+Attributes.swift │ │ │ ├── User+HTTP.swift │ │ │ ├── User+JSON.swift │ │ │ └── User.swift │ │ └── UserStatus │ │ │ └── UserStatus.swift │ ├── Services │ │ ├── AppConfig.swift │ │ └── DecodingTypeMismatch.swift │ └── Utilities │ │ └── HashCommand.swift └── Run │ └── main.swift ├── Tests ├── AppTests │ └── AppTests.swift └── LinuxMain.swift ├── circle.yml ├── cloud.yml └── license /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/vapor 3 | 4 | ### Vapor ### 5 | Config/secrets 6 | 7 | ### Vapor Patch ### 8 | Package.resolved 9 | Packages 10 | .build 11 | xcuserdata 12 | *.xcodeproj 13 | DerivedData/ 14 | .DS_Store 15 | Config/development 16 | Config/live 17 | Config/fuelish 18 | Config/reviewsender 19 | Config/bubbletree 20 | Config/parkplatz 21 | Config/coretics 22 | # End of https://www.gitignore.io/api/vapor 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM swift:5.0 2 | 3 | ARG ENVIRONMENT 4 | ENV ENVIRONMENT ${ENVIRONMENT:-production} 5 | ENV DEBIAN_FRONTEND noninteractive 6 | ENV TZ=Europe/Berlin 7 | ENV TERM xterm 8 | RUN apt-get update && apt-get -y install wget lsb-release apt-transport-https 9 | RUN wget -q https://repo.vapor.codes/apt/keyring.gpg -O- | apt-key add - 10 | RUN echo "deb https://repo.vapor.codes/apt $(lsb_release -sc) main" | tee /etc/apt/sources.list.d/vapor.list 11 | RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone 12 | 13 | USER root 14 | 15 | RUN apt-get update && apt-get install 16 | RUN mkdir /root/vapor 17 | ADD . /root/vapor 18 | WORKDIR /root/vapor 19 | RUN cd /root/vapor && rm -rf .build 20 | RUN swift package update 21 | RUN swift build --configuration release 22 | #EXPOSE 8080 23 | RUN export JWT_SECRET='ohFcON5JWImUWAa-SCC2yYOsFwlHwj3ZBOqpDNFX6JbOqkGrSaGjQWkieAj1fJhuYpTQq7A__s0G6yujmnE6N-I9UHEqXmKxI87ek9z5uxhzIeIHBS6ToyoXHECMS_jN8MbsM4bjec7FLuO9bVNJALFmCgEwcSzZdP9zFHjlj32ATWuSwXbNHNAJnk2IUk2eYiMNiG1BzZM8OApsCF1ASa9zcXdm2QYtOat7hhP-Uo6y_zflx9Ahg-CUBqPTpfOUUuJoGjeWgbhy0-ISveueGjzj7x5UYKNCRZyCircJ_-v51wFvx1lbgRmqH4eJy0dh8Ra-zmzLsFCDs2Akz8Oy0Q' 24 | RUN export DATABASE_HOSTNAME='users.cpzpcvtsi0py.us-east-1.rds.amazonaws.com' 25 | RUN export DATABASE_USER='users' 26 | RUN export DATABASE_PASSWORD='k3AjY.eHcPVWxWM' 27 | RUN export DATABASE_DB='users' 28 | RUN export USER_JWT_D='IiLd9ex8LnXsFQ52jeK2HYPqf3-o6bT1PR_gM570kT0SkrH6TiwJowFuDTJ14qSIu6L0wPUCxbyRtH8gmqs2xAaXO5Zagj7vaMduAl8NCud_eKePKvxAhKGc9Ip0ApyJZCnCHqhOyZ1P0yyM_bYJLmgvQfQ2K-ByfT5BExLT54EFwUJ63tPQiU0gyycDULZAGTQBPzJNB5yWrVFW6s_VPZo73wd_4r86VErMeMgT0u4Nb5FihOcCjsdHt8X43oU4sf-YnHdzO7reHS8g11JLHrWL_sQlrC-gtJFq88UTzsevdsziDTByuB-Kf8cPATXPhTaisEb-TuURR_61wGLbQQ' 29 | CMD .build/release/Run --hostname=0.0.0.0 --port=8080 30 | 31 | -------------------------------------------------------------------------------- /Documentation.md: -------------------------------------------------------------------------------- 1 | # User Service Route Documentation 2 | 3 | The User Service handles user authentication with JWT (JSON Web Tokens) and keeps custom data in user attributes. 4 | 5 | ## Routes: 6 | 7 | Marked routes require a JWT access token passed in with the `Authorization` header with OAuth 2 format: 8 | 9 | Bearer {{token}} 10 | 11 | --- 12 | 13 | ### OPTIONS `/*` 14 | 15 | Returns `options allowed` text response 16 | 17 | **Paramaters:** `N/A` 18 | 19 | **Response:** 20 | 21 | options allowed 22 | 23 | --- 24 | 25 | ### POST `/*/users/accessToken` 26 | 27 | Gets a JWT (JSON Web Token) access token that can be used to authenticate with other services. 28 | 29 | The payload contains the following keys: 30 | 31 | | key | Type | 32 | |-------------------|--------| 33 | | `permissionLevel` | `int` | 34 | | `firstname` | String | 35 | | `lastname` | String | 36 | | `language` | String | 37 | | `exp` | Int | 38 | | `iat` | Int | 39 | | `email` | String | 40 | | `id` | String | 41 | 42 | The payload may contain other values depending on the User Service configuration. 43 | 44 | **Parameters:** 45 | 46 | | Name | Type | Description | Required | 47 | |-----------------|----------|------------------------------------------------|----------| 48 | | `refreshToken` | `string` | A refresh token that is accessed at user login | True | 49 | 50 | **Response:** 51 | 52 | { 53 | "status": "success", 54 | "accessToken": "eyJjcml0IjpbImV4cCIsImF1ZCJdLCJhbGciOiJSUzI1NiIsImtpZCI6InJldmlld3NlbmRlciJ9.eyJlbWFpbCI6ImNhbGViLmtsZXZldGVyQGdtYWlsLmNvbSIsImxhc3RuYW1lIjoiIiwiZmlyc3RuYW1lIjoiIiwiaWQiOjEsImxhbmd1YWdlIjoiZW4iLCJwZXJtaXNzaW9uTGV2ZWwiOjAsImV4cCI6IjE1MTQzMzEzMzMiLCJpYXQiOiIxNTE0MzI3NzMzIn0.Q0vQ75qFyhokHUL1kjRYlxClfUVyWB8Eq4Dpm6xAOydWO1iQ7ykafx1_92q4te2SdSzGS3Wr-0buhbUSmkuxF9nrYzGcyN0uqthu5ML0HLKLIELV0lGEYn5xN5tvMscfIcF6sF80F2bXp6XRR02vELqVQOYQNvEc8ir0ZwHfVDe9BR8rc4sTK_Ox5cVaI5ZMkKV75VWnBUjU8s5ijlbVavkTlcom9EE1g7I1rDvNnNHS4toe2BOU9xoS93BdTXhvbsG-r40F-AZ1QmUBEgDm4FUyDivCafXIpQu-wAWVAXUNHpkrkiGRzJW-E6Nvlf1rlg7zgnRImBVF9qeY4KsKLQ" 55 | } 56 | 57 | --- 58 | 59 | ### POST, GET `/*/users/activate` 60 | 61 | Confirms the user, allowing them to authenticate. This route is used when the service is configured for email confimation. Otherwise, the user is already confirmed. 62 | 63 | **Parameters:** 64 | 65 | | Name | Type | Description | Required | 66 | |---------|----------|---------------------------------------------------------|----------| 67 | | `code` | `string` | The confimation code that was generated on registration | True | 68 | 69 | **Response:** 70 | 71 | { 72 | "status": "success", 73 | "user": { 74 | "confirmed": true, 75 | "firstname": "", 76 | "lastname": "", 77 | "email": "fourth@example.com", 78 | "id": 1, 79 | "language": "en", 80 | "permissionLevel": 0 81 | } 82 | } 83 | 84 | --- 85 | 86 | ### GET `/*/users/health` 87 | 88 | Used by AWS to check if the E2C instance should be rebooted. 89 | 90 | **Parameters:** `N/A` 91 | 92 | **Response:** 93 | 94 | all good 95 | 96 | --- 97 | 98 | ### POST `/*/users/login` 99 | 100 | Authenticates a user, sending back a refresh token and an access token. 101 | 102 | **Parameters:** 103 | 104 | | Name | Type | Description | Required | 105 | |------------|----------|---------------------------------------------------|----------| 106 | | `email` | `string` | The email of the user that is authenticating | True | 107 | | `password` | `string` | The raw password to check against the user's hash | True | 108 | 109 | **Response:** 110 | 111 | { 112 | "refreshToken": "eyJjcml0IjpbImV4cCIsImF1ZCJdLCJhbGciOiJSUzI1NiIsImtpZCI6InJldmlld3NlbmRlciJ9.eyJpZCI6MSwiaWF0IjoiMTUxNDMyODkzOSIsImV4cCI6IjE1MTY5MjA5MzkifQ.Oi2SO5szl2qUZ3uYpTrrqV1hj13iMIHHW24_wkoIe7BRm8t0aT6SeY5eDPIPzRv9aa_bmELFam9sK-mYhpu4q19OAG-FtJZO5VdZWskalbY7BwQv3t7nZOXuFioMmhHwdk6yBHkkl-lOdTSHhQEhO0P94kws2dfgwGtIR8mjs1sLyT3TefQJi5hVhfRzIH2cO7X7co8s0Fph_AtCUsgtAGV-m_NsvViDqqDLAdrr7WXWSrjQkr0P-h331oW2M7IM5FzN5FmBhvR_ehw8D9ERwAFjJL5MN7CTMYAUX-7qC4MOoI5Eh3Y1Ot46auEY1u-dt2notkWr3VaS5TCfaCrafg", 113 | "status": "success", 114 | "user": { 115 | "confirmed": true, 116 | "firstname": "", 117 | "lastname": "", 118 | "email": "fourth@example.com", 119 | "id": 1, 120 | "language": "en", 121 | "permissionLevel": 0 122 | }, 123 | "accessToken": "eyJjcml0IjpbImV4cCIsImF1ZCJdLCJhbGciOiJSUzI1NiIsImtpZCI6InJldmlld3NlbmRlciJ9.eyJlbWFpbCI6ImZvdXJ0aEBleG1hcGxlLmNvbSIsImxhc3RuYW1lIjoiIiwiZmlyc3RuYW1lIjoiIiwiaWQiOjEsImxhbmd1YWdlIjoiZW4iLCJwZXJtaXNzaW9uTGV2ZWwiOjAsImV4cCI6IjE1MTQzMzI1MzkiLCJpYXQiOiIxNTE0MzI4OTM5In0.IVgj3QGINdk-7dbYXZtCXEBIe5ILEufGmuI6p3CnfOP_mmZ6UD70DeekKxqI-5RP5nt5gYBU6QMG5ovSDJMNqi1u4CR0RFmAW9sLck-pjyrkH9H8hsWWQNpTZC7XE8TpXBhqqmyC9wycb8E_2-LXdI5G2yHDwBRBFl8e7m-booi_o7M6sLkHL_X8SFNLoCFPrqVQ3oNVRS4zR2f7aHcKqoqxjtgQ3sjSjNylDIuUcVy3alz554xwdHUYnFHk9L9GmqoGRfIKGTyiZ1E5I5DK45v-RVOSQc6ts70rtQ2lH_RPcb1CE_22VnZVfFWC7HSy0_Dlw4w6MGWoQ5APFv2qwQ" 124 | } 125 | 126 | --- 127 | 128 | ### POST `/*/users/newPassword` 129 | 130 | Resets the user's password hash. This route will then send an email to the address that was passed in with the new password. 131 | 132 | **Parameters:** 133 | 134 | | Name | Type | Description | Required | 135 | |------------|----------|--------------------------------------------------|----------| 136 | | `email` | `string` | The email of the user to update the password for | True | 137 | 138 | **Response:** 139 | 140 | { 141 | "status": "success", 142 | "user": { 143 | "confirmed": true, 144 | "firstname": "", 145 | "lastname": "", 146 | "email": "fourth@example.com", 147 | "id": 1, 148 | "language": "en", 149 | "permissionLevel": 0 150 | } 151 | } 152 | 153 | --- 154 | 155 | ### POST `/*/users/register` 156 | 157 | Creates a new user. The user may or may not be auto-confirmed based on the service configuration. 158 | 159 | 160 | **Parameters:** 161 | 162 | | Name | Type | Description | Required | 163 | |------------|----------|---------------------------------------------|----------| 164 | | `email` | `string` | The email for the user that will be created | True | 165 | | `password` | `string` | The raw password for the new user | True | 166 | 167 | 168 | **Response:** 169 | 170 | { 171 | "status": "success", 172 | "user": { 173 | "confirmed": false, 174 | "firstname": null, 175 | "lastname": null, 176 | "email": "user@example.com", 177 | "id": 2, 178 | "language": "en", 179 | "permissionLevel": 0 180 | } 181 | } 182 | 183 | --- 184 | 185 | ### POST `/*/users/attributes` 186 | 187 | Creates a new custom attribute with a key and and value for a user. If a attribute with the key pased in already exists, the value of the attribute will be updated. 188 | 189 | **Requires Access Token** 190 | 191 | **Parameters:** 192 | 193 | | Name | Type | Description | Required | 194 | |-----------------|----------|-------------------------------------------|----------| 195 | | `attributeKey` | `string` | An identifier that is unique for the user | True | 196 | | `attributeText` | `string` | The value for the attribute | True | 197 | 198 | **Response:** 199 | 200 | { 201 | "status": "success", 202 | "user": { 203 | "confirmed": false, 204 | "attributes": { 205 | "key_one": "env=\"CLIENT_ID=91838_018381_0381980\"" 206 | }, 207 | "firstname": "", 208 | "lastname": "", 209 | "email": "user@example.com", 210 | "id": 2, 211 | "language": "en", 212 | "permissionLevel": 0 213 | } 214 | } 215 | 216 | --- 217 | 218 | ### GET `/*/users/attributes` 219 | 220 | Returns all the user's attributes in a JSON object. 221 | 222 | **Requires Access Token** 223 | 224 | **Parameters:** `N/A` 225 | 226 | **Response:** 227 | 228 | { 229 | "key_two": "env=\"CLIENT_SECRET=???\"", 230 | "key_one": "env=\"CLIENT_ID=91838_018381_0381980\"" 231 | } 232 | 233 | --- 234 | 235 | ### DELETE `/*/users/attributes` 236 | 237 | Removes an attribute from a user. 238 | 239 | **Requires Access Token** 240 | 241 | **Parameters:** 242 | 243 | | Name | Type | Description | Required | 244 | |-----------------|----------|--------------------------------------------|-----------| 245 | | `attributeKey` | `string` | The database ID of the attribute to remove | False | 246 | | `attributeText` | `string` | The key for the user's attribute to remove | False | 247 | Note: You do have to pass in one of the above parameters. 248 | 249 | **Response:** 250 | 251 | { 252 | "status": "success", 253 | "user": { 254 | "confirmed": false, 255 | "attributes": { 256 | "key_one": "env=\"CLIENT_ID=91838_018381_0381980\"" 257 | }, 258 | "firstname": "", 259 | "lastname": "", 260 | "email": "user@example.com", 261 | "id": 2, 262 | "language": "en", 263 | "permissionLevel": 0 264 | } 265 | } 266 | 267 | --- 268 | 269 | ### POST `/*/users/profile` 270 | 271 | Updates a user's `firstname` and `lastname` attributes. 272 | 273 | **Requires Access Token** 274 | 275 | **Request Data** 276 | 277 | | Name | Type | Description | Required | 278 | |-------------|----------|-----------------------------------------|-----------| 279 | | `firstname` | `string` | The new value of the user's `firstname` | False | 280 | | `lastname` | `string` | The new value of the user's `lastname` | False | 281 | 282 | Note: Any parameters that are not passed in will set the value of the user's property to `nil`. 283 | 284 | **Response:** 285 | 286 | { 287 | "status": "success", 288 | "user": { 289 | "confirmed": true, 290 | "attributes": { 291 | "key_one": "env=\"CLIENT_ID=91838_018381_0381980\"" 292 | }, 293 | "firstname": "Barney", 294 | "lastname": "Fife", 295 | "email": "user@example.com", 296 | "id": 2, 297 | "language": "en", 298 | "permissionLevel": 0 299 | } 300 | } 301 | 302 | --- 303 | 304 | ### GET `/*/users/profile` 305 | 306 | Returns data about the user appropriate for a profile. 307 | 308 | **Requires Access Token** 309 | 310 | **Parameters:** `N/A` 311 | 312 | **Response:** 313 | 314 | { 315 | "status": "success", 316 | "user": { 317 | "confirmed": true, 318 | "attributes": { 319 | "key_one": "env=\"CLIENT_ID=91838_018381_0381980\"" 320 | }, 321 | "firstname": "Barney", 322 | "lastname": "Fife", 323 | "email": "user@example.com", 324 | "id": 2, 325 | "language": "en", 326 | "permissionLevel": 0 327 | } 328 | } 329 | 330 | --- 331 | 332 | ### GET `/*/users/status` 333 | 334 | Returns a JSON representation of the current user. This endpoint does not return the user's attributes in the JSON response. 335 | 336 | **Requires Access Token** 337 | 338 | **Parameters:** `N/A` 339 | 340 | **Response:** 341 | 342 | { 343 | "status": "success", 344 | "user": { 345 | "confirmed": false, 346 | "firstname": "", 347 | "lastname": "", 348 | "email": "user@example.com", 349 | "id": 2, 350 | "language": "en", 351 | "permissionLevel": 0 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /Localizations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Hello Swift!", 3 | "greeting.message": "Hi %{full-name}!", 4 | "unread.messages": { 5 | "one": "You have one unread message.", 6 | "other": "You have %{count} unread messages." 7 | }, 8 | "email.activation.title": "Aktivierung", 9 | "email.activation.text": "Aktivieren Sie bitte Ihren Account: %{url}", 10 | "email.password.title": "Neues Passwort", 11 | "email.password.text": "Ihr neues Passwort ist: %{password}" 12 | } 13 | -------------------------------------------------------------------------------- /Localizations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Hello Swift!", 3 | "greeting.message": "Hi %{full-name}!", 4 | "unread.messages": { 5 | "one": "You have one unread message.", 6 | "other": "You have %{count} unread messages." 7 | }, 8 | "email.activation.title": "Activation", 9 | "email.activation.text": "Please activate your account: %{url}", 10 | "email.password.title": "New Password", 11 | "email.password.text": "Your new password is: %{password}" 12 | } 13 | -------------------------------------------------------------------------------- /Localizations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Bonjour Swift!", 3 | "greeting.message": "Bonjour %{full-name}!", 4 | "unread.messages": { 5 | "one": "Vous avez un nouveau message.", 6 | "other": "Vous avez %{count} nouveaux messages." 7 | }, 8 | "email.activation.title": "Activation", 9 | "email.activation.text": "Merci d'activer votre compte : %{url}", 10 | "email.password.title": "Nouveau mot de passe", 11 | "email.password.text": "Votre nouveau mot de passe est : %{password}" 12 | } 13 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "users", 7 | products: [ 8 | .library(name: "App", targets: ["App"]), 9 | .executable(name: "Run", targets: ["Run"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/vapor/vapor.git", from: "3.3.0"), 13 | .package(url: "https://github.com/vapor/fluent-mysql.git", from: "3.0.1"), 14 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"), 15 | .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", from: "1.0.0"), 16 | .package(url: "https://github.com/vapor-community/sendgrid-provider.git", from: "3.0.6"), 17 | .package(url: "https://github.com/vapor-community/lingo-vapor.git", from: "3.0.0"), 18 | .package(url: "https://github.com/skelpo/JWTDataProvider.git", from: "1.0.0"), 19 | .package(url: "https://github.com/skelpo/JWTVapor.git", from: "0.13.0"), 20 | .package(url: "https://github.com/skelpo/SkelpoMiddleware.git", from: "1.4.0") 21 | ], 22 | targets: [ 23 | .target(name: "App", dependencies: ["Vapor", "FluentMySQL", "JWT", "CryptoSwift", "SendGrid", "LingoVapor", "JWTDataProvider", "JWTVapor", "SkelpoMiddleware"], 24 | exclude: [ 25 | "Config", 26 | "Public", 27 | "Resources", 28 | ]), 29 | .target(name: "Run", dependencies: ["App"]), 30 | .testTarget(name: "AppTests", dependencies: ["App"]) 31 | ] 32 | ) 33 | 34 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: Run serve --env production --port $PORT --hostname 0.0.0.0 2 | -------------------------------------------------------------------------------- /Public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skelpo/UserManager/bb46d38fdfb6e9ba3a290673945959ad13ab29d1/Public/.gitkeep -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Skelpo User Manager Service 2 | 3 | The Skelpo User Service is an application micro-service written using Swift and Vapor, a server side Swift framework. This micro-service can be integrated into most applications to handle the app's users and authentication. It is designed to be easily customizable, whether that is adding additional data points to the user, getting more data with the authentication payload, or creating more routes. 4 | 5 | Please note: This is not a project that you should just use _out-of-the-box_. Rather it should serve as a template in your own custom micro-service infrastructure. You may be able to use the service _as is_ for your project, but by no means expect it to cover everything you want by default. 6 | 7 | ## Getting Started 8 | 9 | ### MySQL 10 | 11 | Clone down the repo and create a MySQL database called `service_users`: 12 | 13 | ```bash 14 | ~$ mysql -u root -p 15 | Enter password: 16 | Welcome to the MySQL monitor. Commands end with ; or \g. 17 | Your MySQL connection id is 138 18 | Server version: 5.7.21 Homebrew 19 | 20 | Copyright (c) 2000, 2018, Oracle and/or its affiliates. All rights reserved. 21 | 22 | Oracle is a registered trademark of Oracle Corporation and/or its 23 | affiliates. Other names may be trademarks of their respective 24 | owners. 25 | 26 | Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. 27 | 28 | mysql> CREATE DATABASE service_users; 29 | ``` 30 | 31 | Any of the values for the database configuration can be modified as desired. 32 | 33 | The configuration for the database use environment variables for the credentials, name, and host of the database: 34 | 35 | - `DATABASE_HOSTNAME`: The host of the service. If you are running MySQL locally, this will most likely be `localhost`. 36 | - `DATABASE_USER`: The owner of the database, most likely `root` or your user. 37 | - `DATABASE_PASSWORD` The password for the database. If you don't have a password for the database, you don't need to create this env var. 38 | - `DATABASE_DB`: The name of the database. 39 | 40 | The names of the environment variables are the same ones used by Vapor Cloud, so you should be able to connect to a hosted database without an issue. 41 | 42 | ### JWT 43 | 44 | You will also need to create an environment variable named `JWT_PUBLIC` with the `n` value of the JWK to verify access tokens. This service also signs the access tokens, so you will need another environment variable (called `JWT_SECRET` by default, but that can be changed) that contains the `d` value of the JWK. 45 | 46 | ### Email 47 | 48 | This service uses [SendGrid](https://sendgrid.com/) to send account verification and password reset emails. The service accesses your API key through another environment variable called `SENDGRID_API_KEY`. Set that and you should be good to go. 49 | 50 | You can run the service and access its routes through localhost! 51 | 52 | ### Additional Configuration 53 | 54 | There are few other things to note when configuring the service: 55 | 56 | 1. There is a global constant called `emailConfirmation` in the `configure.swift` file. By default, it is set to `false`, which means the user can login and start using the service right away. If you set it to `true`, it requires the user to confirm with an email that is sent to them before they can authenticate with the service. 57 | 58 | 2. There is a `JWTDataConfig` service registered in the `configure(_:_:_:_)` function. The objects stored in this service (of type `RemoteDataClient`) are used to get data outside the service that needs to be stored in the access token payload. 59 | 60 | The `filters` property is an array of the keys to sub-objects in the JSON returned from the remote service. This works with arrays, so if you have an array of objets, all with an `id` value, you can use `["id"]`, and get an array of the `id`s. 61 | 62 | The authentication allowed by this configuration is a bit constrained at this time. It uses an access token generated by the User Service to authenticate with other services, so you can only authenticate with services that use your User Service. The access token that is passed through will only ever contain the basic payload and then will have the additional data added before being returned from the service's authentication route. 63 | 64 | When the data is retrieved from the outside service, the JSON is added to the access token payload with the JSON value fetch as the value and the service name as the key. 65 | 66 | ## Authentication 67 | 68 | To authenticate in a separate service a request that contains an access token from the user service, you will need to setup the [JWTVapor package](https://github.com/skelpo/JWTVapor) in your project. You can reference the code the register it in the User service configuration if you need help setting it up. 69 | 70 | Once you have the JWTVapor provider configured, you will need to add a middleware to your protected routes to verify the access token. If you just want to verify the access token and get the payload, the [JWTMIddleware package](https://github.com/skelpo/JWTMiddleware)'s `JWTVerificationMiddleware` can be used. The JWTMiddleware package also adds a `.payload` helper method to the `Request` class so you can get the access token's payload. 71 | 72 | The other option for middleware is the `JWTAuthenticatableMiddleware` that also comes with the JWTMiddleware package. It handles authentication with a certain type (i.e. `User`) that acts as an owner for the rest of the service's models. 73 | 74 | ## Routes 75 | 76 | To add custom routes to your user service, create a controller in the `Sources/App/Controllers` directory. You can make it a `RouteCollection`, or have the route registration work some other way. After you have created all your routes, you can register it in `Sources/Configuration/router.swift` to the `router` object passed into the `routes(_:)` function. If you want the routes to be protected so the client needs to have an access token, You can create a route group with `JWTAuthenticatableMiddlware` middleware. 77 | 78 | The routes for the service all start with a wildcard path element. This allows you to run multiple different versions of your API (with paths `/v1/...`, `/v2/...`, etc.) on any given cloud provider using a load balancer to figure out where to send the request to so we get the proper API version, while at the same time letting us ignore the version number. We don't need to know if it is correct or not. The load balancer takes care of that. 79 | 80 | ## User 81 | 82 | You can add additional properties to the `User` model if you want, though if the service is already running, they will have to be optional (unless you want to set the values of the rows in the database, but that is beyond the scope of this document). 83 | 84 | Add the property to the model. If you want it to be in the user JSON, then add it to the `UserResponse` struct also. 85 | 86 | 87 | ### Attributes 88 | 89 | The `user` database table is connected to another table, called `attributes`. An attribute's row contains the ID of the user that owns it, a key that is unique to the user, and a value stored as a string. 90 | 91 | When working in the service, if you want to interact with a user's attributes, there are several methods available: 92 | 93 | ```swift 94 | /// Create a query that gets all the attributes belonging to a user. 95 | func attributes(on connection: DatabaseConnectable)throws -> QueryBuilder 96 | 97 | /// Creates a dictionary where the key is the attribute's key and the value is the attribute's text. 98 | func attributesMap(on connection: DatabaseConnectable)throws -> Future<[String:String]> 99 | 100 | /// Creates a profile attribute for the user. 101 | /// 102 | /// - parameters: 103 | /// - key: A public identifier for the attribute. 104 | /// - text: The value of the attribute. 105 | func createAttribute(_ key: String, text: String, on connection: DatabaseConnectable)throws -> Future 106 | 107 | /// Removed the attribute from the user based on its key. 108 | func removeAttribute(key: String, on connection: DatabaseConnectable)throws -> Future 109 | 110 | /// Remove the attribute from the user based on its database ID. 111 | func removeAttribute(id: Int, on connection: DatabaseConnectable)throws -> Future 112 | ``` 113 | 114 | If you want to access the user's attributes through an API endpoint, there are the following routes available: 115 | 116 | - `GET /*/users/attributes`: 117 | Gets all the attributes of the currently authenticated user. This route does not require any parameters. 118 | 119 | - `POST /*/users/attributes` 120 | Creates a new attribute, or sets the value of an existing value. 121 | 122 | This route requires `attributeText` and `attributeKey` parameters in the request body. If an attribute already exists with the key passed in, then the value will be changed to the one passed in. Otherwise, a new attribute will be created. 123 | 124 | - `DELETE /*/users/attributes`: 125 | Deletes an attribute from a user. 126 | 127 | This route requires either an `attributeId` or `attributeKey` parameter in the request body to identify the attribute to delete. 128 | 129 | ## Easy Start 130 | 131 | The easiest way to start the service is by using the Dockerfile. The following command _could_ be used as it is, we highly recommend however that you customize your setup to your needs. 132 | ```bash 133 | # Note that you still need to setup the database before this step. 134 | docker build . -t users 135 | docker run -e JWT_PUBLIC='n-value-from-jwt' \ 136 | -e DATABASE_HOSTNAME='localhost' \ 137 | -e DATABASE_USER='users_service' \ 138 | -e DATABASE_PASSWORD='users_service' \ 139 | -e DATABASE_DB='users_service' \ 140 | -e JWT_SECRET='d-value-from-jwt' -p 8080:8080 users 141 | ``` 142 | 143 | If you want to run the server without docker the following summary of ENV variables that are needed may be helpful: 144 | ```bash 145 | export JWT_PUBLIC='n-value-from-jwt' 146 | export DATABASE_HOSTNAME='localhost' 147 | export DATABASE_USER='users_service' 148 | export DATABASE_PASSWORD='users_service' 149 | export DATABASE_DB='users_service' 150 | export JWT_SECRET='d-value-from-jwt' 151 | ``` 152 | 153 | ## More Information about JWT and JWKS 154 | 155 | You'll notice that this project uses JWT and JWKS for authentication. If you are unfamiliar with the concept the following two links will provide you with an overview: 156 | - [JWKS](https://auth0.com/docs/jwks) 157 | - [JWT](https://jwt.io/) 158 | 159 | ## Todo & Roadmap 160 | The following features will come at some point soon: 161 | - Admin-Functions: List of all users, Editing of other users based on permission level, Adding new users as an admin 162 | - (open for suggestions) 163 | 164 | ## Contribution & License 165 | 166 | This project is published under MIT and is free for any kind of use. Contributions in forms of PRs are more than welcome, please keep the following simple rules: 167 | 168 | - Keep it generic: This user manager should be equally usable for shops, blogs, apps, websites, enterprise systems or any other user system. 169 | - Less is more: There are many features that _could_ be added. Most of them are better suited for additional services though (like e.g. customer related fields etc.) 170 | - Improvements are always great and so are alternatives: If you want this to run with MongoDB, Elastic or anything else - by all means feel free to contribute. Improvements are always great! 171 | -------------------------------------------------------------------------------- /Sources/App/Configuration/boot.swift: -------------------------------------------------------------------------------- 1 | import Routing 2 | import Vapor 3 | 4 | /// Called after your application has initialized. 5 | /// 6 | /// [Learn More →](https://docs.vapor.codes/3.0/getting-started/structure/#bootswift) 7 | public func boot(_ app: Application) throws {} 8 | -------------------------------------------------------------------------------- /Sources/App/Configuration/configure.swift: -------------------------------------------------------------------------------- 1 | import SkelpoMiddleware 2 | import JWTDataProvider 3 | import Authentication 4 | import FluentMySQL 5 | import LingoVapor 6 | import JWTVapor 7 | import SendGrid 8 | import Vapor 9 | 10 | /// Used to check wheather we should send a confirmation email when a user creates an account, 11 | /// or if they should be auto-confirmed. 12 | /// - Note: This variable is set through the environment variable "EMAIL_CONFIRMATION" and "on/off" as values. 13 | var emailConfirmation: Bool = true 14 | 15 | /// The configuration key for wheather user registration is open to the public 16 | /// or must be executed by an admin user. 17 | /// - Note: This variable can be set through the environment variable "OPEN_REGISTRATION" and "on/off" as values. 18 | var openRegistration: Bool = false 19 | 20 | /// Called before your application initializes. 21 | /// 22 | /// https://docs.vapor.codes/3.0/getting-started/structure/#configureswift 23 | public func configure( 24 | _ config: inout Config, 25 | _ env: inout Environment, 26 | _ services: inout Services 27 | ) throws { 28 | let jwtProvider = JWTProvider { n, d in 29 | guard let d = d else { throw Abort(.internalServerError, reason: "Could not find environment variable 'JWT_SECRET'", identifier: "missingEnvVar") } 30 | 31 | let headers = JWTHeader(alg: "RS256", crit: ["exp", "aud"], kid: "user_manager_kid") 32 | return try RSAService(n: n, e: "AQAB", d: d, header: headers) 33 | } 34 | 35 | /// Register providers first 36 | try services.register(LingoProvider(defaultLocale: "")) 37 | try services.register(AuthenticationProvider()) 38 | try services.register(FluentMySQLProvider()) 39 | try services.register(SendGridProvider()) 40 | try services.register(StorageProvider()) 41 | try services.register(jwtProvider) 42 | 43 | /// Register routes to the router 44 | services.register(Router.self) { container -> EngineRouter in 45 | let router = EngineRouter.default() 46 | try routes(router, container) 47 | return router 48 | } 49 | 50 | /// Register middleware 51 | var middlewares = MiddlewareConfig() // Create _empty_ middleware config 52 | middlewares.use(CORSMiddleware()) // Adds Cross-Origin referance headers to reponses where the request had an 'Origin' header. 53 | middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response 54 | middlewares.use(APIErrorMiddleware(environment: env, specializations: [ // Catches all errors and formats them in a JSON response. 55 | ModelNotFound(), 56 | DecodingTypeMismatch() 57 | ])) 58 | services.register(middlewares) 59 | 60 | /// Register the configured SQLite database to the database config. 61 | var databases = DatabasesConfig() 62 | let config = MySQLDatabaseConfig( 63 | hostname: Environment.get("DATABASE_HOSTNAME") ?? "localhost", 64 | port: Int(Environment.get("DATABASE_PORT") ?? "3306") ?? 3306, 65 | username: Environment.get("DATABASE_USER") ?? "root", 66 | password: Environment.get("DATABASE_PASSWORD") ?? "password", 67 | database: Environment.get("DATABASE_DB") ?? "service_users", 68 | transport: env.isRelease ? .cleartext : .unverifiedTLS 69 | ) 70 | let database = MySQLDatabase(config: config) 71 | databases.add(database: database, as: .mysql) 72 | services.register(databases) 73 | 74 | /// Configure migrations 75 | var migrations = MigrationConfig() 76 | migrations.add(model: User.self, database: .mysql) 77 | migrations.add(model: Attribute.self, database: .mysql) 78 | services.register(migrations) 79 | 80 | var commands = CommandConfig.default() 81 | commands.use(HashCommand(), as: "hash") 82 | services.register(commands) 83 | 84 | let jwt = JWTDataConfig() 85 | services.register(jwt) 86 | 87 | let sendgridKey = Environment.get("SENDGRID_API_KEY") ?? "Create Environemnt Variable" 88 | services.register(SendGridConfig(apiKey: sendgridKey)) 89 | 90 | let emailFrom = Environment.get("EMAIL_FROM") ?? "info@skelpo.com" 91 | let emailURL = Environment.get("EMAIL_URL") ?? "http://localhost:8080/v1/users/activate" 92 | 93 | emailConfirmation = Environment.get("EMAIL_CONFIRMATION")=="on" 94 | openRegistration = Environment.get("OPEN_REGISTRATION")=="on" 95 | 96 | /// Register the `AppConfig` service, 97 | /// used to store arbitrary data. 98 | services.register(AppConfig(emailURL: emailURL, emailFrom: emailFrom)) 99 | } 100 | -------------------------------------------------------------------------------- /Sources/App/Configuration/router.swift: -------------------------------------------------------------------------------- 1 | import Routing 2 | import Vapor 3 | import JWTMiddleware 4 | 5 | /// Register your application's routes here. 6 | /// 7 | /// [Learn More →](https://docs.vapor.codes/3.0/getting-started/structure/#routesswift) 8 | public func routes(_ router: Router, _ container: Container) throws { 9 | let root = router.grouped(any, "users") 10 | 11 | // Create a 'health' route useed by AWS to check if the server needs a re-boot. 12 | root.get("health") { _ in 13 | return "all good" 14 | } 15 | 16 | let jwtService = try container.make(JWTService.self) 17 | 18 | try root.register(collection: AdminController()) 19 | try root.register(collection: AuthController(jwtService: jwtService)) 20 | try root.grouped(JWTAuthenticatableMiddleware()).register(collection: UserController()) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/App/Controllers/AdminController.swift: -------------------------------------------------------------------------------- 1 | import JWTMiddleware 2 | import FluentMySQL 3 | import Vapor 4 | 5 | /// A Controller for Admin specific functionality. 6 | final class AdminController: RouteCollection { 7 | 8 | /// Registers routes to the incoming router. 9 | /// 10 | /// - parameters: 11 | /// - router: `Router` to register any new routes to. 12 | func boot(router: Router) throws { 13 | 14 | // Create a route-group that only allows 15 | // admin users to access the endpoint. 16 | let admin = router.grouped( 17 | PermissionsMiddleware(allowed: [.admin]), 18 | JWTVerificationMiddleware() 19 | ) 20 | 21 | // Register handlers with route paths. 22 | 23 | // `self.allUsers` to `GET /*/users`. 24 | admin.get(use: allUsers) 25 | 26 | // These routes decode the request's body to a custom type. 27 | 28 | // `self.editUser` to `PATCH /*/users/:user`. 29 | admin.patch(UserUpdate.self, at: User.parameter, use: editUser) 30 | 31 | // `self.deleteUser` to `DELETE /*/users/:user`. 32 | admin.delete(User.parameter, use: deleteUser) 33 | 34 | // `self.editAttribute` to `PATCH /*/attributes/:attribute`. 35 | admin.patch(AttributeUpdate.self, at: "attributes", Attribute.parameter, use: editAttribute) 36 | } 37 | 38 | /// Gets all user models with their attributes. 39 | func allUsers(_ request: Request)throws -> Future { 40 | 41 | // Get optional lower and upper indexs for user range. 42 | // If no valus are passed in, all users will be fetched. 43 | let bottomIndex = try request.query.get(Int?.self, at: "bottomIndex") ?? 0 44 | let upperIndex = try request.query.get(Int?.self, at: "upperIndex") 45 | 46 | // Fetch all user models from the database. 47 | return User.query(on: request).range(lower: bottomIndex, upper: upperIndex).all().flatMap(to: [([Attribute], User)].self) { users in 48 | 49 | // Get the attributes for each user, and connect them in a tuple. 50 | return try users.map { user in 51 | return try user.attributes(on: request).all().and(result: user) 52 | }.flatten(on: request) 53 | }.map(to: [UserResponse].self) { responses in 54 | 55 | // Convert each Attibutes/User pair to a `UserRepsonse` instance. 56 | return responses.map { response in 57 | return UserResponse(user: response.1, attributes: response.0) 58 | } 59 | 60 | // Create the final response body with the user responses. 61 | }.map(AllUsersSuccessResponse.init) 62 | } 63 | 64 | /// Updates a user's properties in the database. 65 | func editUser(_ request: Request, _ body: UserUpdate)throws -> Future { 66 | 67 | // Get the user with the current path from the database. 68 | let user = try request.parameters.next(User.self) 69 | return user.flatMap(to: User.self) { user in 70 | 71 | // Update each property that appears in the body. 72 | user.firstname = body.firstname ?? user.firstname 73 | user.lastname = body.lastname ?? user.lastname 74 | user.email = body.email ?? user.email 75 | user.language = body.language ?? user.language 76 | user.confirmed = body.confirmed ?? user.confirmed 77 | user.permissionLevel = body.permissionLevel ?? user.permissionLevel 78 | 79 | // Verify the updated property values of the user. 80 | try user.validate() 81 | 82 | // Save the updated user to the database and convert it to a `UserResponse`. 83 | return user.update(on: request) 84 | }.response(on: request, forProfile: true) 85 | } 86 | 87 | func deleteUser(_ request: Request)throws -> Future { 88 | 89 | // Get the user from request parameter to delete. 90 | return try request.parameters.next(User.self).flatMap { user in 91 | 92 | // Delete the user attributes before the user itself. 93 | return try user.attributes(on: request).delete().transform(to: user) 94 | }.flatMap { user in 95 | 96 | // Delete the user and return the 204 (No Content) HTTP status. 97 | return user.delete(on: request).transform(to: .noContent) 98 | } 99 | } 100 | 101 | /// Updates an attribute's value in the database with a given ID. 102 | func editAttribute(_ request: Request, _ body: AttributeUpdate)throws -> Future { 103 | 104 | // Get the attribute with the current path from the database. 105 | let attribute = try request.parameters.next(Attribute.self) 106 | return attribute.flatMap(to: Attribute.self) { attribute in 107 | 108 | // Update the attribute's value and update the instance in the database. 109 | attribute.text = body.value ?? attribute.text 110 | return attribute.update(on: request) 111 | } 112 | } 113 | } 114 | 115 | /// The response structure for `AdminController.allUsers` route. 116 | struct AllUsersSuccessResponse: Content { 117 | let status: String = "success" 118 | let users: [UserResponse] 119 | } 120 | 121 | /// The request body structure for `AdminController.editUser` handler. 122 | struct UserUpdate: Content { 123 | let firstname: String? 124 | let lastname: String? 125 | let email: String? 126 | let language: String? 127 | let confirmed: Bool? 128 | let permissionLevel: UserStatus? 129 | } 130 | 131 | /// The request body structure for `AdminController.editAttribute` handler. 132 | struct AttributeUpdate: Content { 133 | let value: String? 134 | } 135 | -------------------------------------------------------------------------------- /Sources/App/Controllers/AuthController.swift: -------------------------------------------------------------------------------- 1 | import JWTDataProvider 2 | import JWTMiddleware 3 | import CryptoSwift 4 | import LingoVapor 5 | import SendGrid 6 | import Crypto 7 | import Fluent 8 | import Vapor 9 | import JWT 10 | 11 | /// A route controller that handles user authentication with JWT. 12 | final class AuthController: RouteCollection { 13 | private let jwtService: JWTService 14 | 15 | init(jwtService: JWTService) { 16 | self.jwtService = jwtService 17 | } 18 | 19 | func boot(router: Router) throws { 20 | let auth = router.grouped("current") 21 | auth.post("newPassword", use: newPassword) 22 | auth.post("accessToken", use: refreshAccessToken) 23 | 24 | let protected = auth.grouped(JWTAuthenticatableMiddleware()) 25 | protected.post("login", use: login) 26 | protected.get("status", use: status) 27 | 28 | if emailConfirmation { 29 | auth.get("activate", use: activate) 30 | } 31 | 32 | if openRegistration { 33 | auth.post(User.self, at: "register", use: register) 34 | } else { 35 | let restricted = auth.grouped(PermissionsMiddleware(allowed: [.admin])) 36 | restricted.post(User.self, at: "register", use: register) 37 | } 38 | } 39 | 40 | /// Creates a new `User` model in the database. 41 | func register(_ request: Request, _ user: User)throws -> Future { 42 | // Validate the properties of the new user model using custom validations. 43 | try user.validate() 44 | 45 | // Make sure no user exists yet with the email pssed in. 46 | let count = User.query(on: request).filter(\.email == user.email).count() 47 | return count.map(to: User.self) { count in 48 | guard count < 1 else { throw Abort(.badRequest, reason: "This email is already registered.") } 49 | return user 50 | }.flatMap(to: User.self) { (user) in 51 | user.password = try BCrypt.hash(user.password) 52 | 53 | // Get the langauge used to translate text to with Lingo. 54 | if let language = request.http.headers["Language"].first { 55 | // Set the user's `language` property if `language` is not `nil`. 56 | user.language = language 57 | } 58 | 59 | if emailConfirmation { 60 | // Generate a unique code to verify the user with from the current date and time. 61 | user.emailCode = Date().description.md5() 62 | } 63 | 64 | return user.save(on: request) 65 | }.flatMap(to: User.self) { (user) in 66 | if emailConfirmation && !user.confirmed { 67 | guard let confirmation = user.emailCode else { 68 | throw Abort(.internalServerError, reason: "Confirmation code was not set") 69 | } 70 | let config = try request.make(AppConfig.self) 71 | 72 | return try request.send( 73 | email: "email.activation.text", 74 | withSubject: "email.activation.title", 75 | from: config.emailFrom, 76 | to: user.email, 77 | localized: user, 78 | interpolations: ["url": config.emailURL + confirmation] 79 | ).transform(to: user) 80 | } else { 81 | return request.future(user) 82 | } 83 | } 84 | 85 | // Convert the user to its reponse representation 86 | // and return is with a success status. 87 | .response(on: request, forProfile: true) 88 | } 89 | 90 | /// A route handler that checks that status of the user. 91 | /// This could be used to verifiy if they are authenticated. 92 | func status(_ request: Request)throws -> Future { 93 | 94 | // Return the authenticated user. 95 | return try request.user().response(on: request, forProfile: false) 96 | } 97 | 98 | /// A route handler that generates a new password for a user. 99 | func newPassword(_ request: Request)throws -> Future { 100 | 101 | // Get the email of the user to create a new password for. 102 | let email = try request.content.syncGet(String.self, at: "email") 103 | 104 | // Verify a user exists with the given email. 105 | let user = User.query(on: request).filter(\.email == email).first().unwrap(or: Abort(.badRequest, reason: "No user found with email '\(email)'.")) 106 | return user.flatMap(to: (User, String).self) { user in 107 | 108 | // Verifiy that the user has confimed their account. 109 | guard user.confirmed else { throw Abort(.badRequest, reason: "User not activated.") } 110 | 111 | 112 | // Create a new random password from the current date/time 113 | let str = Date().description.md5() 114 | let index = str.index(str.startIndex, offsetBy: 8) 115 | let password = String(str[.. Future<[String: String]> { 141 | // Get refresh token from request body and verify it. 142 | let refreshToken = try request.content.syncGet(String.self, at: "refreshToken") 143 | let refreshJWT = try JWT(from: refreshToken, verifiedUsing: self.jwtService.signer) 144 | try refreshJWT.payload.verify(using: self.jwtService.signer) 145 | 146 | // Get the user with the ID that was just fetched. 147 | let userID = refreshJWT.payload.id 148 | let user = User.find(userID, on: request).unwrap(or: Abort(.badRequest, reason: "No user found with ID '\(userID)'.")) 149 | 150 | return user.flatMap(to: (JSON, Payload).self) { user in 151 | 152 | guard user.confirmed else { throw Abort(.badRequest, reason: "User not activated.") } 153 | 154 | // Construct the new access token payload 155 | let payload = try App.Payload(user: user) 156 | return try request.payloadData(self.jwtService.sign(payload), with: ["userId": "\(user.requireID())"], as: JSON.self).and(result: payload) 157 | }.map(to: [String: String].self) { payloadData in 158 | let payload = try payloadData.0.merge(payloadData.1.json()) 159 | 160 | // Return the signed token with a success status. 161 | let token = try self.jwtService.sign(payload) 162 | return ["status": "success", "accessToken": token] 163 | } 164 | } 165 | 166 | /// Confirms a new user account. 167 | func activate(_ request: Request)throws -> Future { 168 | 169 | // Get the user from the database with the email code from the request. 170 | let code = try request.query.get(String.self, at: "code") 171 | let user = User.query(on: request).filter(\.emailCode == code).first().unwrap(or: Abort(.badRequest, reason: "No user found with the given code.")) 172 | 173 | return user.flatMap(to: User.self) { user in 174 | guard !user.confirmed else { throw Abort(.badRequest, reason: "User already activated.") } 175 | 176 | // Update the confimation properties and save to the database. 177 | user.confirmed = true 178 | user.emailCode = nil 179 | return user.update(on: request) 180 | }.response(on: request, forProfile: false) 181 | } 182 | 183 | /// Authenticates a user with an email and password. 184 | /// The actual authentication is handled by the `JWTAuthenticatableMiddleware`. 185 | /// The request's body should contain an email and a password for authenticating. 186 | func login(_ request: Request)throws -> Future { 187 | let user = try request.requireAuthenticated(User.self) 188 | let userPayload = try Payload(user: user) 189 | 190 | // Create a payload using the standard data 191 | // and the data from the registered `DataService`s 192 | let remotePayload = try request.payloadData( 193 | self.jwtService.sign(userPayload), 194 | with: ["userId": "\(user.requireID())"], 195 | as: JSON.self 196 | ) 197 | 198 | // Create a response form the access token, refresh token. and user response data. 199 | return remotePayload.map(to: LoginResponse.self) { remotePayload in 200 | let payload = try remotePayload.merge(userPayload.json()) 201 | 202 | let accessToken = try self.jwtService.sign(payload) 203 | let refreshToken = try self.jwtService.sign(RefreshToken(user: user)) 204 | 205 | guard user.confirmed else { throw Abort(.badRequest, reason: "User not activated.") } 206 | 207 | let userResponse = UserResponse(user: user, attributes: nil) 208 | return LoginResponse(accessToken: accessToken, refreshToken: refreshToken, user: userResponse) 209 | } 210 | } 211 | } 212 | 213 | struct UserSuccessResponse: Content { 214 | let status: String = "success" 215 | let user: UserResponse 216 | } 217 | 218 | struct LoginResponse: Content { 219 | let status = "success" 220 | let accessToken: String 221 | let refreshToken: String 222 | let user: UserResponse 223 | } 224 | 225 | extension Request { 226 | 227 | /// Sends an email with the SendGrid provider. 228 | /// The email is translated to the user's `langauge` value. 229 | /// 230 | /// - Parameters: 231 | /// - body: The body of the email. 232 | /// - subject: The subject of the email. 233 | /// - address: The email address to send the email to. 234 | /// - user: The user to localize the email to. 235 | /// - interpolations: The interpolation values to replace placeholders in the email body. 236 | func send(email body: String, withSubject subject: String, from: String, to address: String, localized user: User, interpolations: [String: String])throws -> Future { 237 | 238 | // Fetch the service from the request so we can translate the email. 239 | let lingo = try self.lingo() 240 | 241 | // Create the SendGrid client servi e so we can send the email. 242 | let client = try self.make(SendGridClient.self) 243 | 244 | // Translate the subject and body. 245 | let subject: String = lingo.localize(subject, locale: user.language) 246 | let body: String = lingo.localize(body, locale: user.language, interpolations: interpolations) 247 | 248 | // Put all the data for the email togeather 249 | let name = [user.firstname, user.lastname].compactMap({ $0 }).joined(separator: " ") 250 | let from = EmailAddress(email: from, name: nil) 251 | let address = EmailAddress(email: address, name: name) 252 | let header = Personalization(to: [address], subject: subject) 253 | let email = SendGridEmail(personalizations: [header], from: from, subject: subject, content: [[ 254 | "type": "text", 255 | "value": body 256 | ]]) 257 | 258 | return try client.send([email], on: self) 259 | } 260 | } 261 | 262 | extension Future { 263 | 264 | /// Converts a future's value to a dictionary 265 | /// using the value in the future as the dictionary's value. 266 | /// 267 | /// - Parameter key: The key for the value in the dictionary. 268 | /// 269 | /// - Returns: A dictionary instance of type `[String: T]`, 270 | /// using the `key` value passed in as the key and the 271 | /// value from the future as the value. 272 | func keyed(_ key: String) -> Future<[String: T]> { 273 | return self.map(to: [String: T].self) { [key: $0] } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /Sources/App/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | import JWTMiddleware 2 | import Fluent 3 | import Vapor 4 | 5 | /// A controller for routes that interact with the `users` database table. 6 | /// Conformance to the `RouteCollection` protocol allows the controller's 7 | /// route to be registered with a route builder like so: 8 | /// 9 | /// router.register(collection: UserController()) 10 | final class UserController: RouteCollection { 11 | 12 | /// Conforms the `UserController` class 13 | /// to `RouteCollection`. 14 | /// 15 | /// This method is used to register 16 | /// the route handlers to route paths. 17 | /// 18 | /// - Parameter router: The router to 19 | /// register the routes with. 20 | func boot(router: Router) { 21 | let users = router.grouped("current") 22 | 23 | users.get("profile", use: profile) 24 | users.get("attributes", use: attributes) 25 | 26 | users.post(NewUserBody.self, at: "profile", use: save) 27 | users.post(AttributeBody.self, at: "attributes", use: createAttribute) 28 | 29 | users.delete("attribute", use: deleteAttributes) 30 | users.delete("user", use: delete) 31 | } 32 | 33 | /// Gets the profile data for the authenticated user. 34 | /// The requeat passed in should be sent through the 35 | /// 36 | /// `JWTAuthenticatableMiddleware()` first to verify 37 | /// the request and get the user. 38 | func profile(_ request: Request)throws -> Future { 39 | 40 | // Get the authenticated user and convert it to a `UserResponse` instance. 41 | return try request.user().response(on: request, forProfile: true) 42 | } 43 | 44 | /// Updates the authenticates user's `firstname` and 45 | /// `lastname` properties. 46 | func save(_ request: Request, _ content: NewUserBody)throws -> Future { 47 | 48 | // Get the authenticated user, then updates its properties 49 | // with the request body data. 50 | let user = try request.user() 51 | 52 | user.firstname = content.firstname ?? "" 53 | user.lastname = content.lastname ?? "" 54 | 55 | // Save the updated user, then return a `UserResponse` instance. 56 | return user.update(on: request).response(on: request, forProfile: true) 57 | } 58 | 59 | /// Gets all the `Attribute` models connected to the 60 | /// authenticated user. 61 | func attributes(_ request: Request)throws -> Future<[Attribute]> { 62 | return try request.user().attributes(on: request).all() 63 | } 64 | 65 | /// Adds or updates an attribute for the authenticated user. 66 | func createAttribute(_ request: Request, _ content: AttributeBody)throws -> Future { 67 | let user = try request.user() 68 | 69 | // Get the attribute with the matching key. 70 | // If one exists, update its `text` property, 71 | // otherwise create a new one. 72 | return Attribute.query(on: request).filter(\.key == content.attributeKey).first().flatMap(to: Attribute.self) { attribute in 73 | if let attribute = attribute { 74 | attribute.text = content.attributeText 75 | return attribute.save(on: request) 76 | } else { 77 | return try user.createAttribute(content.attributeKey, text: content.attributeText, on: request) 78 | } 79 | 80 | // Convert the authenticated user to a `UserResponse`. 81 | }.transform(to: user).response(on: request, forProfile: true) 82 | } 83 | 84 | /// Deletes a `User` model, along with its connected attributes. 85 | /// The authed user that is deleting the other user must be an admin. 86 | func delete(_ request: Request)throws -> Future { 87 | 88 | // Get the authenticated user. 89 | let user = try request.user() 90 | 91 | // Delete all the `Attribute` models connected to 92 | // the user, then delete the user. 93 | return try user.attributes(on: request).delete().transform(to: user).flatMap(to: HTTPStatus.self) { user in 94 | return user.delete(on: request).transform(to: .noContent) 95 | } 96 | } 97 | 98 | /// Deletes an `Attribute` model connected to the authed user, 99 | /// using either its ID or `key` to find it. 100 | func deleteAttributes(_ request: Request)throws -> Future { 101 | let user = try request.user() 102 | let deleted: Future 103 | 104 | // If we have a key in the request body, find the connected `Attribute` 105 | // with that and delete it. If no key is present, try to use the ID. 106 | // If neither key or ID are present, abort. 107 | if let key = try request.content.syncGet(String?.self, at: "attributeKey") { 108 | deleted = try user.attributes(on: request).filter(\.key == key).delete() 109 | } else if let id = try request.content.syncGet(Attribute.ID?.self, at: "attributeId") { 110 | deleted = try user.attributes(on: request).filter(\.id == id).delete() 111 | } else { 112 | throw Abort(.badRequest, reason: "Missing 'attributeId/attributeKey' data from request") 113 | } 114 | 115 | // Once the deletion is complete, return a 204 (No Content) status code. 116 | return deleted.transform(to: .noContent) 117 | } 118 | } 119 | 120 | /// A representation of a request body for 121 | /// creating a new user attribute. 122 | struct AttributeBody: Content { 123 | let attributeKey: String 124 | let attributeText: String 125 | } 126 | 127 | /// A representation of a request body 128 | /// for creating a new user. 129 | struct NewUserBody: Content { 130 | let firstname: String? 131 | let lastname: String? 132 | } 133 | -------------------------------------------------------------------------------- /Sources/App/Models/AccessToken.swift: -------------------------------------------------------------------------------- 1 | import SkelpoMiddleware 2 | import Foundation 3 | import Crypto 4 | import Vapor 5 | import JSON 6 | import JWT 7 | 8 | /// A representation of the payload used in the access tokens 9 | /// for this service's authentication. 10 | struct Payload: PermissionedUserPayload { 11 | let status: UserStatus 12 | let firstname: String? 13 | let lastname: String? 14 | let language: String 15 | let exp: TimeInterval 16 | let iat: TimeInterval 17 | let email: String 18 | let id: User.ID 19 | 20 | init(user: User, expiration: TimeInterval = 3600)throws { 21 | let now = Date().timeIntervalSince1970 22 | 23 | self.status = user.permissionLevel 24 | self.firstname = user.firstname 25 | self.lastname = user.lastname 26 | self.language = user.language 27 | self.exp = now + expiration 28 | self.iat = now 29 | self.email = user.email 30 | self.id = try user.requireID() 31 | } 32 | 33 | func verify(using signer: JWTSigner) throws { 34 | let expiration = Date(timeIntervalSince1970: self.exp) 35 | try ExpirationClaim(value: expiration).verifyNotExpired() 36 | } 37 | } 38 | 39 | /// Payload data for a refresh token 40 | struct RefreshToken: IdentifiableJWTPayload { 41 | let id: User.ID 42 | let iat: TimeInterval 43 | let exp: TimeInterval 44 | 45 | init(user: User, expiration: TimeInterval = 24 * 60 * 60 * 30)throws { 46 | let now = Date().timeIntervalSince1970 47 | 48 | self.id = try user.requireID() 49 | self.iat = now 50 | self.exp = now + expiration 51 | } 52 | 53 | func verify(using signer: JWTSigner) throws { 54 | let expiration = Date(timeIntervalSince1970: self.exp) 55 | try ExpirationClaim(value: expiration).verifyNotExpired() 56 | } 57 | } 58 | 59 | extension JSON: JWTPayload { 60 | public func verify(using signer: JWTSigner) throws { 61 | // Don't do anything 62 | // We only conform to `JWTPayload` 63 | // so we can sign a JWT with JSON as 64 | // it's payload. 65 | } 66 | } 67 | 68 | extension JWTError: AbortError { 69 | public var status: HTTPResponseStatus { 70 | switch self.identifier { 71 | case "exp": return .unauthorized 72 | default: return .internalServerError 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/App/Models/Attribute/Attribute.swift: -------------------------------------------------------------------------------- 1 | import FluentMySQL 2 | import Vapor 3 | 4 | /// An attribute for a `User` to store custom data.. 5 | final class Attribute: Content, MySQLModel, Migration, Parameter { 6 | static let entity: String = "attributes" 7 | 8 | /// The database ID of a class instance. 9 | var id: Int? 10 | 11 | /// The value of the attribute. 12 | var text: String 13 | 14 | /// A key used to reference the attribute unique to the user. 15 | let key: String 16 | 17 | /// The ID of the user that owns the attribute. 18 | let userID: Int 19 | 20 | /// Creates an attribute for a `User`. 21 | /// 22 | /// - parameters: 23 | /// - text: The value for the attribute. 24 | /// - key: A key to reference the attribute from the database, 25 | /// unique in the scope of the user owning it. 26 | /// - userID: The ID of the user owning the attribute. 27 | init(text: String, key: String, userID: Int) { 28 | self.text = text 29 | self.userID = userID 30 | self.key = key 31 | } 32 | } 33 | 34 | extension Attribute { 35 | static func prepare(on connection: MySQLDatabase.Connection) -> Future { 36 | return Database.create(self, on: connection) { builder in 37 | try addProperties(to: builder) 38 | builder.reference(from: \.userID, to: \User.id) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/Models/User/User+Attributes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | extension User { 4 | /// Create a query that gets all the attributes belonging to a user. 5 | func attributes(on connection: DatabaseConnectable)throws -> QueryBuilder { 6 | // Return an `Attribute` query that filters on the `userId` field. 7 | return try Attribute.query(on: connection).filter(\.userID == self.requireID()) 8 | } 9 | 10 | /// Creates a dictionary where the key is the attribute's key and the value is the attribute's text. 11 | func attributesMap(on connection: DatabaseConnectable)throws -> Future<[String:String]> { 12 | 13 | // Get all the user's attributes. 14 | return try self.attributes(on: connection).all().map(to: [String: String].self, { (attributes) in 15 | 16 | // Iterate over the attributes, setting the `response` key (`attribute.key`) to `attribute.text`. 17 | return attributes.reduce(into: [:], { (response, attribute) in 18 | response[attribute.key] = attribute.text 19 | }) 20 | }) 21 | } 22 | 23 | /// Creates a profile attribute for the user. 24 | /// 25 | /// - parameters: 26 | /// - key: A public identifier for the attribute. 27 | /// - text: The value of the attribute. 28 | func createAttribute(_ key: String, text: String, on connection: DatabaseConnectable)throws -> Future { 29 | // Creat and save the attribute to the database. 30 | // The `Model.requireID` method gets the model's ID if it exists, 31 | // otherwise it throws an error. 32 | let attribute = try Attribute(text: text, key: key, userID: self.requireID()) 33 | return attribute.save(on: connection) 34 | } 35 | 36 | /// Removed the attribute from the user bsaed on its key. 37 | func removeAttribute(key: String, on connection: DatabaseConnectable)throws -> Future { 38 | return try self.attributes(on: connection).filter(\.key == key).delete() 39 | } 40 | 41 | /// Remove the attribute from the user based on its database ID. 42 | func removeAttribute(id: Int, on connection: DatabaseConnectable)throws -> Future { 43 | return try self.attributes(on: connection).filter(\.id == id).delete() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/App/Models/User/User+HTTP.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import Crypto 4 | import JWTVapor 5 | import Authentication 6 | import SkelpoMiddleware 7 | 8 | extension Request { 9 | 10 | /// Gets the user object that is stored in the request by `JWTAuthenticatableMiddleware`. 11 | func user()throws -> User { 12 | 13 | // Get the authenticated user from the request's auth cache. 14 | let user = try self.requireAuthenticated(User.self) 15 | 16 | // Get the langauge used to translate text to with Lingo. 17 | if let language = self.http.headers["Language"].first { 18 | 19 | // Set the user's `language` property if `language` is not `nil`. 20 | user.language = language 21 | } 22 | 23 | return user 24 | } 25 | } 26 | 27 | /// Conforms the `User` model to the `BasicJWTAuthenticatable` protocol. 28 | /// This allows verfication of the `User` model with `JWTAuthenticatableMiddleware`. 29 | extension User: BasicJWTAuthenticatable { 30 | 31 | /// The key-path for the property to check against `AuthBody.username` 32 | /// when fetching the user form the database to authenticate. 33 | static var usernameKey: WritableKeyPath { 34 | return \.email 35 | } 36 | 37 | /// Creaes an access token that is used to verify future requests. 38 | func accessToken(on request: Request) throws -> Future { 39 | return Future.map(on: request) { try Payload(user: self) } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/Models/User/User+JSON.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | /// A representation of a `User` model instance 5 | /// which is returned from routes for JSON responses 6 | struct UserResponse: Content { 7 | let id: Int? 8 | let firstname, lastname: String? 9 | let email, language: String 10 | let confirmed: Bool 11 | let permissionLevel: Int 12 | let attributes: [Attribute]? 13 | 14 | #if DEBUG 15 | let emailCode: String? 16 | #endif 17 | 18 | init(user: User, attributes: [Attribute]?) { 19 | self.id = user.id 20 | self.firstname = user.firstname 21 | self.lastname = user.lastname 22 | self.email = user.email 23 | self.language = user.language 24 | self.confirmed = user.confirmed 25 | self.permissionLevel = user.permissionLevel.id 26 | self.attributes = attributes 27 | 28 | #if DEBUG 29 | self.emailCode = user.emailCode 30 | #endif 31 | } 32 | } 33 | 34 | extension User { 35 | 36 | /// Creates a `UserResponse` representation of the current user. 37 | func response(on request: Request, forProfile profile: Bool)throws -> Future { 38 | if !profile { return Future.map(on: request) { UserSuccessResponse(user: UserResponse(user: self, attributes: nil)) } } 39 | 40 | return try self.attributes(on: request).all().map(to: UserSuccessResponse.self) { attributes in 41 | let user = UserResponse(user: self, attributes: attributes) 42 | return UserSuccessResponse(user: user) 43 | } 44 | } 45 | } 46 | 47 | extension Future where T == User { 48 | 49 | /// Creates a `UserResponse` representation of the current user. 50 | func response(on request: Request, forProfile profile: Bool) -> Future { 51 | return self.flatMap(to: UserSuccessResponse.self) { try $0.response(on: request, forProfile: profile) } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/App/Models/User/User.swift: -------------------------------------------------------------------------------- 1 | // 2 | // User.swift 3 | // groups 4 | // 5 | // Created by Ralph Küpper on 7/23/17. 6 | // 7 | // 8 | 9 | import FluentMySQL 10 | import Validation 11 | import Crypto 12 | import Vapor 13 | 14 | /// A generic user that can be conected to any service that uses JWT for authentication. 15 | final class User: Content, MySQLModel, Migration, Parameter { 16 | static let entity: String = "users" 17 | 18 | /// The database ID of the class instance. 19 | var id: Int? 20 | 21 | /// 22 | var firstname: String? 23 | 24 | /// 25 | var lastname: String? 26 | 27 | /// 28 | var email: String 29 | 30 | /// 31 | var password: String 32 | 33 | /// 34 | var language: String 35 | 36 | /// 37 | var emailCode: String? 38 | 39 | /// 40 | var confirmed: Bool 41 | 42 | /// 43 | var permissionLevel: UserStatus 44 | 45 | /// 46 | var deletedAt: Date? 47 | 48 | /// Create a user with an email address and a language. 49 | /// When using this initializer, the user is created with a permission level of 0 and an empty password. 50 | /// 51 | /// - parameters: 52 | /// - email: The user's email address. 53 | /// - language: The user's prefered language for translating the confimation email or any other text with Lingo. 54 | init(_ email: String, _ language: String) throws { 55 | self.email = email 56 | self.language = language 57 | self.password = "" 58 | self.confirmed = !emailConfirmation 59 | self.permissionLevel = .standard 60 | } 61 | 62 | /// Create a user with an email, language, first name, last name, password, and email code. 63 | /// 64 | /// - Parameters: 65 | /// - email: The user's email address 66 | /// - language: The user's prefered language. 67 | /// - firstName: The user's first name. This defaults to `nil`. 68 | /// - lastName: The user's last name. This defaults to `nil`. 69 | /// - password: The user's raw password. The initializer hashes it. 70 | /// - emailCode: The email code for confirming the user account. 71 | /// - Throws: Errors when hashing the password 72 | convenience init(_ email: String, _ language: String, _ firstName: String? = nil, _ lastName: String? = nil, _ password: String, _ emailCode: String)throws { 73 | try self.init(email, language) 74 | 75 | self.firstname = firstName 76 | self.lastname = lastName 77 | self.emailCode = emailCode 78 | self.password = try BCryptDigest().hash(password) 79 | } 80 | 81 | // We implement a custom decoder so we can have default 82 | // values for some of the properties. 83 | init(from decoder: Decoder)throws { 84 | let container = try decoder.container(keyedBy: CodingKeys.self) 85 | 86 | self.id = try container.decodeIfPresent(User.ID.self, forKey: .id) 87 | self.firstname = try container.decodeIfPresent(String.self, forKey: .firstname) 88 | self.lastname = try container.decodeIfPresent(String.self, forKey: .lastname) 89 | self.email = try container.decode(String.self, forKey: .email) 90 | self.password = try container.decode(String.self, forKey: .password) 91 | self.language = try container.decodeIfPresent(String.self, forKey: .language) ?? "en" 92 | self.emailCode = try container.decodeIfPresent(String.self, forKey: .emailCode) 93 | self.confirmed = try container.decodeIfPresent(Bool.self, forKey: .confirmed) ?? !emailConfirmation 94 | self.permissionLevel = try container.decodeIfPresent(UserStatus.self, forKey: .permissionLevel) ?? .standard 95 | self.deletedAt = try container.decodeIfPresent(Date.self, forKey: .deletedAt) 96 | } 97 | 98 | /// Allows Fluent to set the `deletedAt` property to the value stored in the database. 99 | /// Allows a `users` row to be temporarily deleted from the database 100 | /// with the possibility to restore. 101 | static var deletedAtKey: WritableKeyPath? { 102 | return \.deletedAt 103 | } 104 | } 105 | 106 | extension User: Validatable { 107 | static func validations() throws -> Validations { 108 | var validations = Validations(User.self) 109 | try validations.add(\.password, .ascii && .count(6...)) 110 | try validations.add(\.email, .email) 111 | return validations 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/App/Models/UserStatus/UserStatus.swift: -------------------------------------------------------------------------------- 1 | import FluentMySQL 2 | 3 | /// The permission level of a user. 4 | /// 5 | /// An instance of the struct gets stored as an int 6 | /// in a MySQL database using its `id` property. 7 | struct UserStatus: RawRepresentable, Codable, Hashable, MySQLEnumType { 8 | 9 | /// Denotes the user as an administer. They can 10 | /// do (almost) anything. 11 | /// 12 | /// ID: 0, Name: `admin` 13 | static let admin = UserStatus(id: 0, name: "admin") 14 | 15 | /// Denotes a user as a moderator. Less privileges than an 16 | /// admin, more then standard. Mostly they stop arguments. 17 | /// 18 | /// ID: 1, Name: `moderator` 19 | static let moderator = UserStatus(id: 1, name: "moderator") 20 | 21 | /// Denotes that the user is a standard user. The least amount 22 | /// of privileges, though there is still plenty they can do. 23 | /// Unless they hack your service, then they can do a lot... 24 | /// 25 | /// ID: 2, Name: `standard` 26 | static let standard = UserStatus(id: 2, name: "standard") 27 | 28 | /// A storage of all the status names for a given 29 | /// status ID. When you initialize a new status, this 30 | /// storage gets updated. 31 | static private(set) var statuses: [Int: String] = [ 32 | 0: "admin", 33 | 1: "moderator", 34 | 2: "standard" 35 | ] 36 | 37 | /// The base value of the status. 38 | /// This value is what appears in a 39 | /// JSON representation or the database. 40 | let id: Int 41 | 42 | /// A human readable name for the 43 | /// status. Default value is `custom-` 44 | let name: String 45 | 46 | /// The `id` of the status. This property 47 | /// is required by the `RawRepresentable` protocol. 48 | var rawValue: Int { return self.id } 49 | 50 | /// Creates a new `UserStatus`. 51 | /// 52 | /// - Parameters: 53 | /// - id: The identefier for the new status. 54 | /// - name: The human readable name for the status. 55 | /// If `nil` is passed in, it defaults to `custom-`. 56 | /// The name will be set to a stored name if one exists. 57 | init(id: Int, name: String?) { 58 | self.id = id 59 | 60 | if let name = UserStatus.statuses[id]{ 61 | self.name = name 62 | } else { 63 | self.name = name ?? "custom-\(id)" 64 | UserStatus.statuses[id] = self.name 65 | } 66 | } 67 | 68 | init(from decoder: Decoder)throws { 69 | let container = try decoder.singleValueContainer() 70 | let id = try container.decode(Int.self) 71 | self = .init(rawValue: id) 72 | } 73 | 74 | init(rawValue value: Int) { self = .init(id: value, name: nil) } 75 | 76 | func encode(to encoder: Encoder)throws { 77 | var container = encoder.singleValueContainer() 78 | try container.encode(id) 79 | } 80 | 81 | static func reflectDecoded() throws -> (UserStatus, UserStatus) { 82 | return (.admin, .standard) 83 | } 84 | } 85 | 86 | extension UserStatus: ExpressibleByIntegerLiteral { 87 | init(integerLiteral value: Int) { 88 | self = .init(rawValue: value) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/App/Services/AppConfig.swift: -------------------------------------------------------------------------------- 1 | import Service 2 | 3 | struct AppConfig: ServiceType { 4 | static func makeService(for worker: Container) throws -> AppConfig { 5 | return AppConfig() 6 | } 7 | 8 | /// The URL that the user activate 9 | /// to confirm their account. 10 | var emailURL: String = "" 11 | 12 | /// The email address that the 13 | /// confirmation and password 14 | /// reset emails are sent from. 15 | var emailFrom: String = "" 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/App/Services/DecodingTypeMismatch.swift: -------------------------------------------------------------------------------- 1 | import APIErrorMiddleware 2 | import Vapor 3 | 4 | /// Converts `DecodingError.typeMismatch` errors to a human friendly response. 5 | struct DecodingTypeMismatch: ErrorCatchingSpecialization { 6 | func convert(error: Error, on request: Request) -> ErrorResult? { 7 | if case let DecodingError.typeMismatch(type, context) = error { 8 | let path = context.codingPath.map { $0.stringValue }.joined(separator: ".") 9 | return ErrorResult(message: "Decoding request body failed. Make sure the '\(path)' key exists and is of type '\(type)'", status: .badRequest) 10 | } 11 | return nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Utilities/HashCommand.swift: -------------------------------------------------------------------------------- 1 | import Command 2 | import Crypto 3 | import Vapor 4 | 5 | final class HashCommand: Command { 6 | var arguments: [CommandArgument] = [CommandArgument.argument(name: "string", help: ["Ths string to hash"])] 7 | var options: [CommandOption] = [] 8 | var help: [String] = ["Hashes a string with the BCrypt algorithm"] 9 | 10 | func run(using context: CommandContext) throws -> EventLoopFuture { 11 | try context.console.print(BCrypt.hash(context.argument("string"))) 12 | return context.container.future() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Service 3 | import Vapor 4 | import Foundation 5 | 6 | // The contents of main are wrapped in a do/catch block because any errors that get raised to the top level will crash Xcode 7 | do { 8 | var config = Config.default() 9 | var env = try Environment.detect() 10 | var services = Services.default() 11 | 12 | try App.configure(&config, &env, &services) 13 | 14 | let app = try Application( 15 | config: config, 16 | environment: env, 17 | services: services 18 | ) 19 | 20 | try App.boot(app) 21 | 22 | try app.run() 23 | } catch { 24 | print(error) 25 | exit(1) 26 | } 27 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import CryptoSwift 3 | import Crypto 4 | @testable import App 5 | 6 | public class AppTests: XCTestCase { 7 | func testBCrypt() { 8 | do { 9 | let hash = try BCrypt.hash("password") 10 | print(hash) 11 | } catch let error { 12 | print(error) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | #if os(Linux) 2 | 3 | import XCTest 4 | @testable import AppTests 5 | 6 | XCTMain([ 7 | ]) 8 | 9 | #endif 10 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | override: 3 | - eval "$(curl -sL https://apt.vapor.sh)" 4 | - sudo apt-get install vapor 5 | - sudo chmod -R a+rx /usr/ 6 | test: 7 | override: 8 | - swift build 9 | - swift build -c release 10 | - swift test 11 | -------------------------------------------------------------------------------- /cloud.yml: -------------------------------------------------------------------------------- 1 | swift_version: "4.0.0" 2 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Skelpo Inc. 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. 22 | --------------------------------------------------------------------------------