├── .github └── workflows │ └── run-build.yml ├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── dataSources.xml ├── gradle.xml ├── jarRepositories.xml ├── misc.xml ├── runConfigurations.xml ├── uiDesigner.xml └── vcs.xml ├── LICENSE ├── Procfile ├── README.md ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts ├── src └── main │ ├── kotlin │ └── dev │ │ └── zlagi │ │ ├── application │ │ ├── Application.kt │ │ ├── auth │ │ │ ├── JWTController.kt │ │ │ ├── PasswordEncryptor.kt │ │ │ ├── firebase │ │ │ │ ├── FirebaseAdmin.kt │ │ │ │ ├── FirebaseAuth.kt │ │ │ │ ├── FirebaseConfig.kt │ │ │ │ └── FirebaseUserPrincipal.kt │ │ │ └── principal │ │ │ │ └── UserPrincipal.kt │ │ ├── controller │ │ │ ├── BaseController.kt │ │ │ ├── auth │ │ │ │ └── AuthController.kt │ │ │ ├── blog │ │ │ │ └── BlogController.kt │ │ │ └── di │ │ │ │ └── ControllerModule.kt │ │ ├── exception │ │ │ └── ErrorExceptions.kt │ │ ├── model │ │ │ ├── request │ │ │ │ ├── BlogRequest.kt │ │ │ │ ├── IdpAuthenticationRequest.kt │ │ │ │ ├── Notification.kt │ │ │ │ ├── NotificationMessage.kt │ │ │ │ ├── NotificationRequest.kt │ │ │ │ ├── RefreshTokenRequest.kt │ │ │ │ ├── ResetPasswordRequest.kt │ │ │ │ ├── RevokeTokenRequest.kt │ │ │ │ ├── SignInRequest.kt │ │ │ │ ├── SignUpRequest.kt │ │ │ │ └── UpdatePasswordRequest.kt │ │ │ └── response │ │ │ │ ├── AccountResponse.kt │ │ │ │ ├── AuthResponse.kt │ │ │ │ ├── BlogResponse.kt │ │ │ │ ├── BlogsResponse.kt │ │ │ │ ├── GeneralResponse.kt │ │ │ │ ├── HttpResponse.kt │ │ │ │ ├── Response.kt │ │ │ │ └── TokenResponse.kt │ │ ├── plugins │ │ │ ├── Koin.kt │ │ │ ├── Monitoring.kt │ │ │ ├── Routing.kt │ │ │ ├── Security.kt │ │ │ └── Serialization.kt │ │ ├── router │ │ │ ├── AuthRouter.kt │ │ │ └── BlogRouter.kt │ │ └── utils │ │ │ └── StringExt.kt │ │ └── data │ │ ├── dao │ │ ├── BlogsDao.kt │ │ ├── TokenDao.kt │ │ └── UserDao.kt │ │ ├── database │ │ ├── DatabaseProvider.kt │ │ └── table │ │ │ ├── Blogs.kt │ │ │ ├── Tokens.kt │ │ │ └── Users.kt │ │ ├── di │ │ └── DaoModule.kt │ │ ├── entity │ │ ├── EntityBlog.kt │ │ ├── EntityToken.kt │ │ └── EntityUser.kt │ │ └── model │ │ ├── BlogDataModel.kt │ │ ├── Token.kt │ │ └── User.kt │ └── resources │ ├── application.conf │ ├── ktor-firebase-auth-firebase-adminsdk.json │ └── logback.xml └── system.properties /.github/workflows/run-build.yml: -------------------------------------------------------------------------------- 1 | name: Build (API) 2 | on: 3 | push 4 | 5 | jobs: 6 | build: 7 | name: Build API 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | 13 | - name: Set up JDK 14 | uses: actions/setup-java@v1 15 | with: 16 | java-version: 11 17 | 18 | - name: Cache Gradle 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/noty-android/.gradle/caches/ 22 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} 23 | restore-keys: | 24 | ${{ runner.os }}-gradle- 25 | 26 | - name: Cache Gradle wrapper 27 | uses: actions/cache@v2 28 | with: 29 | path: ~/noty-android/.gradle/wrapper/ 30 | key: cache-clean-wrapper-${{ runner.os }}-${{ matrix.jdk }} 31 | 32 | - name: Grant Permission to Execute 33 | run: chmod +x gradlew 34 | 35 | - name: 🏗 Build with Gradle 🛠️ 36 | run: ./gradlew build --stacktrace 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project exclude paths 2 | /.gradle/ 3 | /build/ -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | postgresql 6 | true 7 | org.postgresql.Driver 8 | jdbc:postgresql://localhost:3500/blogfy 9 | $ProjectFileDir$ 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 34 | 35 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/uiDesigner.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: build/install/blogfy-api/bin/blogfy-api 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blogfy (API) 2 | 3 | [![Build (API)](https://github.com/Zlagi/blogfy-api/actions/workflows/run-build.yml/badge.svg)](https://github.com/Zlagi/blogfy-api/actions/workflows/run-build.yml) 4 | [![Kotlin](https://img.shields.io/badge/kotlin-1.6.10-orange.svg?logo=kotlin)](http://kotlinlang.org) 5 | [![Ktor](https://img.shields.io/badge/ktor-1.6.7-orange.svg?logo=kotlin)](https://ktor.io) 6 | 7 | Blogfy backend _REST API_ is built with Ktor framework with PostgreSQL as database and deployed on the Heroku. 8 | 9 | Currently this API is deployed on _`https://blogfy-server.herokuapp.com`_. You can try it 😃. 10 | 11 | # INFO 👓 12 | 13 | - If you want to work with your own SDK Admin Firebase take a look here: 14 | - https://levelup.gitconnected.com/how-to-integrate-firebase-authentication-with-ktors-auth-feature-dc2c3893a0cc 15 | 16 | # Features 👓 17 | 18 | - Authentication for email based auth. 19 | - Authentication for Google identity provider (authenticate with Firebase JWT). 20 | - Refresh and revoke Ktor JWT. 21 | - Create, update, and delete blog. 22 | - Check blog author. 23 | - Fetch blogs with pagination. 24 | - Fetch account properties and update account password. 25 | - Send push notifications to android clients. 26 | - Validate requests body and authorization header (custom Ktor JWT challenge). 27 | - Automatic and easy deployment to Heroku. 28 | 29 | 30 | # Package Structure 31 | 32 | dev.zlagi.application # Root Package 33 | . 34 | ├── application # Ktor application entry point and API routes 35 | | ├── auth 36 | | ├── controller 37 | │ ├── exception 38 | │ ├── model 39 | │ ├── plugins 40 | │ ├── router 41 | │ ├── utils 42 | │ └── Application.Kt 43 | │ 44 | | 45 | └── data # Data source and operations. 46 | ├── dao 47 | ├── database 48 | ├── di 49 | ├── entity 50 | └── model 51 | 52 | 53 | # Built With 🛠 54 | - [Ktor](https://ktor.io/) - Ktor is an asynchronous framework for creating microservices, web applications, and more. It’s fun, free, and open source. 55 | - [Firebase Admin](https://firebase.google.com/docs/admin/setup) - The Admin SDK is a set of server libraries that lets you interact with Firebase. 56 | - [One Signal](https://onesignal.com) - An Api for Push Notifications, Email, SMS & In-App.. 57 | - [Exposed](https://github.com/JetBrains/Exposed) - An ORM/SQL framework for Kotlin. 58 | - [PostgreSQL JDBC Driver](https://jdbc.postgresql.org/) - JDBC Database driver for PostgreSQL. 59 | - [HikariCP](https://github.com/brettwooldridge/HikariCP) - High performance JDBC connection pooling. 60 | - [Koin](https://insert-koin.io/docs/reference/koin-ktor/ktor/) - Dependency injection framework. 61 | - [jBCrypt](https://www.mindrot.org/projects/jBCrypt/) - Password hashing algorithm. 62 | - [Commons Email](https://commons.apache.org/email/) - An API for sending email. 63 | 64 | # REST API Specification 65 | 66 | ## Authentication 67 | 68 | ### Sign up 69 | 70 | ```http 71 | POST http://localhost:8080/auth/signup 72 | Content-Type: application/json 73 | 74 | { 75 | "email" : "test@gmail.com", 76 | "username" : "user", 77 | "password": "12346789", 78 | "confirmPassword" : "12346789" 79 | } 80 | 81 | ``` 82 | 83 | ### Sign in 84 | 85 | ```http 86 | POST http://localhost:8080/auth/signin 87 | Content-Type: application/json 88 | 89 | { 90 | "email" : "test@gmail.com", 91 | "password": "12346789" 92 | } 93 | 94 | ``` 95 | 96 | ### Google 97 | #### ⚠️ single endpoint for both signin and signup. 98 | 99 | ```http 100 | POST http://localhost:8080/auth/idp/google 101 | Content-Type: application/json 102 | Authorization: Bearer YOUR_FIREBASE_AUTH_TOKEN 103 | 104 | { 105 | "username" : "user" 106 | } 107 | 108 | ``` 109 | 110 | ### Refresh ktor token 111 | 112 | ```http 113 | POST http://localhost:8080/auth/token/refresh 114 | Content-Type: application/json 115 | 116 | { 117 | "token" : "token" 118 | } 119 | 120 | ``` 121 | 122 | ### Revoke ktor token 123 | 124 | ```http 125 | POST http://localhost:8080/auth/token/revoke 126 | Content-Type: application/json 127 | 128 | { 129 | "token" : "token" 130 | } 131 | 132 | ``` 133 | 134 | ### Send reset password link 135 | 136 | ```http 137 | POST http://localhost:8080/auth/reset-password 138 | Content-Type: application/json 139 | 140 | { 141 | "email" : "test@gmail.com" 142 | } 143 | 144 | ``` 145 | 146 | ### Confirm reset password 147 | 148 | ```http 149 | POST http://localhost:8080/auth/confirm-reset-password?token=KTOR_AUTH_TOKEN 150 | Content-Type: application/json 151 | 152 | { 153 | "currentPassword": "oldpassword", 154 | "newPassword": "newpassword", 155 | "confirmNewPassword": "newpassword" 156 | } 157 | 158 | ``` 159 | 160 | ## Blog operations 161 | 162 | ### Get all blogs by query 163 | 164 | #### ⚠️ without query parameters 165 | ```http 166 | GET http://localhost:8080/blog/list 167 | Content-Type: application/json 168 | Authorization: Bearer KTOR_AUTH_TOKEN 169 | ``` 170 | #### ⚠️ with query parameters 171 | ```http 172 | GET http://localhost:8080/blog/list?search_query=test&page=2&limit=5 173 | Content-Type: application/json 174 | Authorization: Bearer KTOR_AUTH_TOKEN 175 | ``` 176 | 177 | ### Create New Blog 178 | #### ⚠️ creation time is sent from android client side. 179 | 180 | ```http 181 | POST http://localhost:8080/blog 182 | Content-Type: application/json 183 | Authorization: Bearer KTOR_AUTH_TOKEN 184 | 185 | { 186 | "title": "Hey there! This is title", 187 | "description": "Write some description here...", 188 | "creationTime": "Date: 2022-03-07 Time: 22:10:56" 189 | } 190 | ``` 191 | 192 | ### Update Blog 193 | #### ⚠️creation time is sent from android client side. 194 | 195 | ```http 196 | PUT http://localhost:8080/blog/BLOG_ID_HERE 197 | Content-Type: application/json 198 | Authorization: Bearer KTOR_AUTH_TOKEN 199 | 200 | { 201 | "title": "Updated title!", 202 | "note": "Updated body here...", 203 | "creationTime": "Date: 2022-03-07 Time: 22:20:38" 204 | } 205 | ``` 206 | 207 | ### Delete Blog 208 | 209 | ```http 210 | DELETE http://localhost:8080/blog/BLOG_ID_HERE 211 | Content-Type: application/json 212 | Authorization: Bearer KTOR_AUTH_TOKEN 213 | ``` 214 | 215 | ### Check Blog Author 216 | 217 | ```http 218 | DELETE http://localhost:8080/blog/BLOG_ID_HERE/is_author 219 | Content-Type: application/json 220 | Authorization: Bearer KTOR_AUTH_TOKEN 221 | ``` 222 | 223 | ### Send push notifications 224 | 225 | ```http 226 | POST http://localhost:8080/blog/notification 227 | Content-Type: application/json 228 | Authorization: Bearer KTOR_AUTH_TOKEN 229 | ``` 230 | 231 | ## Account operations 232 | 233 | ### Get Account 234 | 235 | ```http 236 | Get http://localhost:8080/account 237 | Content-Type: application/json 238 | Authorization: Bearer KTOR_AUTH_TOKEN 239 | ``` 240 | 241 | ### Update Password 242 | 243 | ```http 244 | PUT http://localhost:8080/account/password 245 | Content-Type: application/json 246 | Authorization: Bearer KTOR_AUTH_TOKEN 247 | 248 | { 249 | "currentPassword": "oldpassword", 250 | "newPassword": "newpassword", 251 | "confirmNewPassword": "newpassword" 252 | } 253 | ``` 254 | 255 | ## Inspiration 256 | 257 | This is project is a sample, to inspire you and should handle most of the common cases, but please take a look at 258 | additional resources. 259 | 260 | ### Android projects 261 | 262 | Other high-quality projects will help you to find solutions that work for your project: 263 | 264 | - [NotyKT](https://github.com/PatilShreyas/NotyKT/tree/master/noty-api) 265 | - [KtorEasy](https://github.com/mathias21/KtorEasy) 266 | - [Ktor-pushnotification](https://github.com/philipplackner/com.plcoding.ktor-pushnotification) 267 | 268 | ## Contribute 269 | 270 | * Bug fixes and Pull Requests are highly appreciated and you're more than welcome to send us your feedbacks <3 271 | 272 | ## License 273 | 274 | Copyright 2022 Haythem Mejerbi. 275 | 276 | Licensed under the Apache License, Version 2.0 (the "License"); 277 | you may not use this file except in compliance with the License. 278 | You may obtain a copy of the License at 279 | 280 | http://www.apache.org/licenses/LICENSE-2.0 281 | 282 | Unless required by applicable law or agreed to in writing, software 283 | distributed under the License is distributed on an "AS IS" BASIS, 284 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 285 | See the License for the specific language governing permissions and 286 | limitations under the License. 287 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | val ktorVersion: String by project 2 | val kotlinVersion: String by project 3 | val logbackVersion: String by project 4 | val koinVersion: String by project 5 | val exposedVersion: String by project 6 | val hikariVersion: String by project 7 | val postgresVersion: String by project 8 | val bcryptVersion: String by project 9 | val firebaseAdminVersion: String by project 10 | val commonsEmailVersion: String by project 11 | val kotestVersion: String by project 12 | val testContainerVersion: String by project 13 | 14 | plugins { 15 | application 16 | kotlin("jvm") version "1.6.10" 17 | id("org.jetbrains.kotlin.plugin.serialization") version "1.6.10" 18 | } 19 | 20 | group = "dev.zlagi" 21 | version = "0.0.1" 22 | application { 23 | mainClass.set("io.ktor.server.netty.EngineMain") 24 | } 25 | 26 | repositories { 27 | mavenCentral() 28 | } 29 | 30 | tasks.create("stage") { 31 | dependsOn("installDist") 32 | } 33 | 34 | dependencies { 35 | implementation("io.ktor:ktor-server-core:$ktorVersion") 36 | implementation("io.ktor:ktor-auth:$ktorVersion") 37 | implementation("io.ktor:ktor-auth-jwt:$ktorVersion") 38 | implementation("io.ktor:ktor-serialization:$ktorVersion") 39 | implementation("io.ktor:ktor-server-netty:$ktorVersion") 40 | implementation("ch.qos.logback:logback-classic:$logbackVersion") 41 | 42 | implementation("io.ktor:ktor-client-core:$ktorVersion") 43 | implementation("io.ktor:ktor-client-serialization:$ktorVersion") 44 | implementation("io.ktor:ktor-client-cio:$ktorVersion") 45 | 46 | // Koin for Kotlin 47 | implementation("io.insert-koin:koin-ktor:$koinVersion") 48 | implementation("io.insert-koin:koin-logger-slf4j:$koinVersion") 49 | 50 | // Exposed 51 | implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") 52 | implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") 53 | implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") 54 | 55 | // Hikari 56 | implementation("com.zaxxer:HikariCP:$hikariVersion") 57 | 58 | // PostgreSQL 59 | implementation("org.postgresql:postgresql:$postgresVersion") 60 | 61 | // Password encryption 62 | implementation("org.mindrot:jbcrypt:$bcryptVersion") 63 | 64 | // For sending reset-password-mail 65 | implementation("org.apache.commons:commons-email:$commonsEmailVersion") 66 | 67 | // Firebase admin 68 | implementation ("com.google.firebase:firebase-admin:$firebaseAdminVersion") 69 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ktorVersion=1.6.7 2 | kotlinVersion=1.6.10 3 | logbackVersion=1.2.11 4 | koinVersion=3.1.5 5 | exposedVersion=0.37.3 6 | postgresVersion=42.3.3 7 | hikariVersion=5.0.1 8 | bcryptVersion=0.4 9 | firebaseAdminVersion=7.1.0 10 | commonsEmailVersion=1.5 11 | kotlin.code.style=official 12 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HaythemMejerbi/Blogfy-api/16e6462cafc347dff822b7dac42449f62e269e66/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MSYS* | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "blogfy-api" -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/Application.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("EXPERIMENTAL_IS_NOT_ENABLED") 2 | 3 | package dev.zlagi.application 4 | 5 | import com.auth0.jwt.interfaces.JWTVerifier 6 | import dev.zlagi.application.auth.firebase.FirebaseAdmin 7 | import dev.zlagi.application.plugins.* 8 | import dev.zlagi.data.dao.UserDao 9 | import dev.zlagi.data.database.DatabaseProviderContract 10 | import io.ktor.application.* 11 | import io.ktor.client.* 12 | import io.ktor.client.engine.cio.* 13 | import io.ktor.client.features.json.* 14 | import io.ktor.client.features.json.serializer.* 15 | import org.koin.core.annotation.KoinReflectAPI 16 | import org.koin.ktor.ext.inject 17 | 18 | fun main(args: Array): Unit = 19 | io.ktor.server.netty.EngineMain.main(args) 20 | 21 | @OptIn(KoinReflectAPI::class) 22 | @Suppress("unused") // application.conf references the main function. This annotation prevents the IDE from marking it as unused. 23 | fun Application.module() { 24 | 25 | val databaseProvider by inject() 26 | val userDao by inject() 27 | val jwtVerifier by inject() 28 | 29 | val client = HttpClient(CIO) { 30 | install(JsonFeature) { 31 | serializer = KotlinxSerializer() 32 | } 33 | } 34 | val apiKey = environment.config.property("onesignal.apiKey").getString() 35 | 36 | configureKoin() 37 | configureMonitoring() 38 | configureSerialization() 39 | configureSecurity(userDao, jwtVerifier) 40 | configureRouting(client, apiKey) 41 | 42 | // initialize database 43 | databaseProvider.init() 44 | 45 | // initialize Firebase Admin SDK 46 | FirebaseAdmin.init() 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/JWTController.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth 2 | 3 | import com.auth0.jwt.JWT 4 | import com.auth0.jwt.JWTVerifier 5 | import com.auth0.jwt.algorithms.Algorithm 6 | import dev.zlagi.application.model.response.TokenResponse 7 | import dev.zlagi.data.model.User 8 | import java.util.* 9 | 10 | object JWTController : TokenProvider { 11 | 12 | private const val secret = "bkFwb2xpdGE2OTk5" 13 | private const val issuer = "bkFwb2xpdGE2OTk5" 14 | private const val validityInMs: Long = 1200000L // 20 Minutes 15 | private const val refreshValidityInMs: Long = 3600000L * 24L * 30L // 30 days 16 | private val algorithm = Algorithm.HMAC512(secret) 17 | 18 | val verifier: JWTVerifier = JWT 19 | .require(algorithm) 20 | .withIssuer(issuer) 21 | .build() 22 | 23 | override fun verifyToken(token: String): Int? { 24 | return verifier.verify(token).claims["userId"]?.asInt() 25 | } 26 | 27 | override fun getTokenExpiration(token: String): Date { 28 | return verifier.verify(token).expiresAt 29 | } 30 | 31 | /** 32 | * Produce token and refresh token for this combination of User and Account 33 | */ 34 | override fun createTokens(user: User) = TokenResponse( 35 | createAccessToken(user, getTokenExpiration()), 36 | createRefreshToken(user, getTokenExpiration(refreshValidityInMs)) 37 | ) 38 | 39 | override fun verifyTokenType(token: String): String { 40 | return verifier.verify(token).claims["tokenType"]!!.asString() 41 | } 42 | 43 | private fun createAccessToken(user: User, expiration: Date) = JWT.create() 44 | .withSubject("Authentication") 45 | .withIssuer(issuer) 46 | .withClaim("userId", user.id) 47 | .withClaim("tokenType", "accessToken") 48 | .withExpiresAt(expiration) 49 | .sign(algorithm) 50 | 51 | private fun createRefreshToken(user: User, expiration: Date) = JWT.create() 52 | .withSubject("Authentication") 53 | .withIssuer(issuer) 54 | .withClaim("userId", user.id) 55 | .withClaim("tokenType", "refreshToken") 56 | .withExpiresAt(expiration) 57 | .sign(algorithm) 58 | 59 | /** 60 | * Calculate the expiration Date based on current time + the given validity 61 | */ 62 | private fun getTokenExpiration(validity: Long = validityInMs) = Date(System.currentTimeMillis() + validity) 63 | } 64 | 65 | interface TokenProvider { 66 | fun createTokens(user: User): TokenResponse 67 | fun verifyTokenType(token: String): String 68 | fun verifyToken(token: String): Int? 69 | fun getTokenExpiration(token: String): Date 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/PasswordEncryptor.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth 2 | 3 | import org.mindrot.jbcrypt.BCrypt 4 | import java.security.SecureRandom 5 | 6 | object PasswordEncryptor : PasswordEncryptorContract { 7 | 8 | private const val letters: String = "abcdefghijklmnopqrstuvwxyz" 9 | private const val uppercaseLetters: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 10 | private const val numbers: String = "0123456789" 11 | private const val special: String = "@#=+!£$%&?" 12 | private const val maxPasswordLength: Float = 20F //Max password lenght that my app creates 13 | private const val maxPasswordFactor: Float = 10F //Max password factor based on chars inside password 14 | // see evaluatePassword function below 15 | 16 | /** 17 | * Generate a random password 18 | * @param isWithLetters Boolean value to specify if the password must contain letters 19 | * @param isWithUppercase Boolean value to specify if the password must contain uppercase letters 20 | * @param isWithNumbers Boolean value to specify if the password must contain numbers 21 | * @param isWithSpecial Boolean value to specify if the password must contain special chars 22 | * @param length Int value with the length of the password 23 | * @return the new password. 24 | */ 25 | fun generatePassword( 26 | isWithLetters: Boolean = true, 27 | isWithUppercase: Boolean = true, 28 | isWithNumbers: Boolean = true, 29 | isWithSpecial: Boolean = true, 30 | length: Int = 6 31 | ): String { 32 | 33 | var result = "" 34 | var i = 0 35 | 36 | if (isWithLetters) { 37 | result += this.letters 38 | } 39 | if (isWithUppercase) { 40 | result += this.uppercaseLetters 41 | } 42 | if (isWithNumbers) { 43 | result += this.numbers 44 | } 45 | if (isWithSpecial) { 46 | result += this.special 47 | } 48 | 49 | val rnd = SecureRandom.getInstance("SHA1PRNG") 50 | val sb = StringBuilder(length) 51 | 52 | while (i < length) { 53 | val randomInt: Int = rnd.nextInt(result.length) 54 | sb.append(result[randomInt]) 55 | i++ 56 | } 57 | 58 | return sb.toString() 59 | } 60 | 61 | override fun validatePassword(attempt: String, userPassword: String): Boolean { 62 | return BCrypt.checkpw(attempt, userPassword) 63 | } 64 | 65 | override fun encryptPassword(password: String): String { 66 | return BCrypt.hashpw(password, BCrypt.gensalt()) 67 | } 68 | 69 | } 70 | 71 | interface PasswordEncryptorContract { 72 | fun validatePassword(attempt: String, userPassword: String): Boolean 73 | fun encryptPassword(password: String): String 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/firebase/FirebaseAdmin.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth.firebase 2 | 3 | import com.google.auth.oauth2.GoogleCredentials 4 | import com.google.firebase.FirebaseApp 5 | import com.google.firebase.FirebaseOptions 6 | import java.io.InputStream 7 | 8 | object FirebaseAdmin { 9 | private val serviceAccount: InputStream? = 10 | this::class.java.classLoader.getResourceAsStream("ktor-firebase-auth-firebase-adminsdk.json") 11 | 12 | private val options: FirebaseOptions = FirebaseOptions.builder() 13 | .setCredentials(GoogleCredentials.fromStream(serviceAccount)) 14 | .build() 15 | 16 | fun init(): FirebaseApp = FirebaseApp.initializeApp(options) 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/firebase/FirebaseAuth.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth.firebase 2 | 3 | import com.google.firebase.ErrorCode 4 | import com.google.firebase.FirebaseException 5 | import com.google.firebase.auth.FirebaseAuth 6 | import com.google.firebase.auth.FirebaseAuthException 7 | import dev.zlagi.application.model.response.GeneralResponse 8 | import io.ktor.application.* 9 | import io.ktor.auth.* 10 | import io.ktor.http.* 11 | import io.ktor.request.* 12 | import io.ktor.response.* 13 | import org.slf4j.Logger 14 | import org.slf4j.LoggerFactory 15 | 16 | private val firebaseAuthLogger: Logger = LoggerFactory.getLogger("dev.zlagi.application.auth.firebase") 17 | 18 | class FirebaseAuthenticationProvider internal constructor(config: Configuration) : AuthenticationProvider(config) { 19 | 20 | internal val token: (ApplicationCall) -> String? = config.token 21 | internal val principle: ((email: String) -> Principal?)? = config.principal 22 | 23 | class Configuration internal constructor(name: String?) : AuthenticationProvider.Configuration(name) { 24 | 25 | internal var token: (ApplicationCall) -> String? = { call -> call.request.parseAuthorizationToken() } 26 | 27 | internal var principal: ((email: String) -> Principal?)? = null 28 | 29 | internal fun build() = FirebaseAuthenticationProvider(this) 30 | } 31 | } 32 | 33 | fun Authentication.Configuration.firebase( 34 | name: String? = null, 35 | configure: FirebaseAuthenticationProvider.Configuration.() -> Unit 36 | ) { 37 | val provider = FirebaseAuthenticationProvider.Configuration(name).apply(configure).build() 38 | provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context -> 39 | 40 | try { 41 | val token = provider.token(call) ?: throw FirebaseAuthException( 42 | FirebaseException( 43 | ErrorCode.UNAUTHENTICATED, 44 | "Authentication failed: Firebase token not found", 45 | null 46 | ) 47 | ) 48 | 49 | val uid = FirebaseAuth.getInstance().verifyIdToken(token).email 50 | 51 | provider.principle?.let { it.invoke(uid)?.let { principle -> context.principal(principle) } } 52 | 53 | } catch (cause: Throwable) { 54 | val message = when (cause) { 55 | is FirebaseAuthException -> { 56 | "Authentication failed: Failed to parse Firebase ID token" 57 | } 58 | else -> return@intercept 59 | } 60 | 61 | firebaseAuthLogger.trace(message) 62 | call.respond(HttpStatusCode.Unauthorized, GeneralResponse.failed(message)) 63 | context.challenge.complete() 64 | finish() 65 | } 66 | } 67 | register(provider) 68 | } 69 | 70 | fun ApplicationRequest.parseAuthorizationToken(): String? = authorization()?.let { 71 | it.split(" ")[1] 72 | } 73 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/firebase/FirebaseConfig.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth.firebase 2 | 3 | import kotlinx.coroutines.runBlocking 4 | 5 | object FirebaseConfig { 6 | fun FirebaseAuthenticationProvider.Configuration.configure() { 7 | principal = { email -> 8 | //this is where you'd make a db call to fetch your User profile 9 | runBlocking { FirebaseUserPrincipal(email) } 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/firebase/FirebaseUserPrincipal.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth.firebase 2 | 3 | import io.ktor.auth.* 4 | 5 | class FirebaseUserPrincipal(val email: String) : Principal -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/auth/principal/UserPrincipal.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.auth.principal 2 | 3 | import dev.zlagi.data.model.User 4 | import io.ktor.auth.* 5 | 6 | class UserPrincipal(val user: User) : Principal -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/controller/BaseController.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.controller 2 | 3 | import dev.zlagi.application.auth.PasswordEncryptorContract 4 | import dev.zlagi.application.auth.TokenProvider 5 | import dev.zlagi.application.exception.BadRequestException 6 | import dev.zlagi.application.exception.UnauthorizedActivityException 7 | import dev.zlagi.application.model.request.SignInRequest 8 | import dev.zlagi.application.model.request.SignUpRequest 9 | import dev.zlagi.application.model.request.UpdatePasswordRequest 10 | import dev.zlagi.application.model.response.AuthResponse 11 | import dev.zlagi.application.utils.isAlphaNumeric 12 | import dev.zlagi.application.utils.isEmailValid 13 | import dev.zlagi.data.dao.TokenDao 14 | import dev.zlagi.data.dao.UserDao 15 | import dev.zlagi.data.model.BlogDataModel 16 | import dev.zlagi.data.model.User 17 | import org.koin.core.component.KoinComponent 18 | import org.koin.core.component.inject 19 | import java.text.SimpleDateFormat 20 | 21 | abstract class BaseController : KoinComponent { 22 | 23 | private val userDao by inject() 24 | private val refreshTokensDao by inject() 25 | private val passwordEncryption by inject() 26 | private val tokenProvider by inject() 27 | private val simpleDateFormat = SimpleDateFormat("'Date: 'yyyy-MM-dd' Time: 'HH:mm:ss") 28 | 29 | internal fun validateSignInFieldsOrThrowException( 30 | signInRequest: SignInRequest 31 | ) { 32 | val message = when { 33 | (signInRequest.email.isBlank() or (signInRequest.password.isBlank())) -> "Credentials fields should not be blank" 34 | (!signInRequest.email.isEmailValid()) -> "Email invalid" 35 | (signInRequest.password.length !in (8..50)) -> "Password should be of min 8 and max 50 character in length" 36 | else -> return 37 | } 38 | 39 | throw BadRequestException(message) 40 | } 41 | 42 | internal fun validateSignUpFieldsOrThrowException( 43 | signUpRequest: SignUpRequest 44 | ) { 45 | val message = when { 46 | (signUpRequest.email.isBlank() or (signUpRequest.username.isBlank()) or (signUpRequest.password.isBlank()) or (signUpRequest.confirmPassword.isBlank())) -> "Fields should not be blank" 47 | (!signUpRequest.email.isEmailValid()) -> "Email invalid" 48 | (!signUpRequest.username.isAlphaNumeric()) -> "No special characters allowed in username" 49 | (signUpRequest.username.length !in (4..30)) -> "Username should be of min 4 and max 30 character in length" 50 | (signUpRequest.password.length !in (8..50)) -> "Password should be of min 8 and max 50 character in length" 51 | (signUpRequest.confirmPassword.length !in (8..50)) -> "Password should be of min 8 and max 50 character in length" 52 | (signUpRequest.password != signUpRequest.confirmPassword) -> "Passwords do not match" 53 | else -> return 54 | } 55 | 56 | throw BadRequestException(message) 57 | } 58 | 59 | internal fun validateResetPasswordFieldsOrThrowException(email: String) { 60 | val message = when { 61 | (email.isBlank()) -> "Email field should not be blank" 62 | else -> return 63 | } 64 | 65 | throw BadRequestException(message) 66 | } 67 | 68 | internal fun validateUpdatePasswordFieldsOrThrowException( 69 | updatePasswordRequest: UpdatePasswordRequest 70 | ) { 71 | val message = when { 72 | (updatePasswordRequest.currentPassword.isBlank() || updatePasswordRequest.newPassword.isBlank() 73 | || updatePasswordRequest.confirmNewPassword.isBlank()) -> { 74 | "Password field should not be blank" 75 | } 76 | updatePasswordRequest.newPassword != updatePasswordRequest.confirmNewPassword -> "Passwords do not match" 77 | updatePasswordRequest.newPassword.length !in (8..50) -> "Password should be of min 8 and max 50 character in length" 78 | updatePasswordRequest.confirmNewPassword.length !in (8..50) -> "Password should be of min 8 and max 50 character in length" 79 | else -> return 80 | } 81 | 82 | throw BadRequestException(message) 83 | } 84 | 85 | internal fun validateTokenParametersOrThrowException(token: String?) { 86 | if (token == null) throw BadRequestException("Missing token query parameter") 87 | } 88 | 89 | internal fun validateRefreshTokenFieldsOrThrowException(token: String) { 90 | val message = when { 91 | (token.isBlank()) -> "Authentication failed: Token field should not be blank" 92 | else -> return 93 | } 94 | 95 | throw BadRequestException(message) 96 | } 97 | 98 | internal fun validateSignOutFieldsOrThrowException(token: String) { 99 | val message = when { 100 | (token.isBlank()) -> "Authentication failed: Token field should not be blank" 101 | else -> return 102 | } 103 | throw BadRequestException(message) 104 | } 105 | 106 | internal fun validateRefreshTokenType(tokenType: String) { 107 | if (tokenType != "refreshToken") throw BadRequestException("Authentication failed: Invalid token type") 108 | } 109 | 110 | internal fun validateAccessTokenType(tokenType: String) { 111 | if (tokenType != "accessToken") throw BadRequestException("Authentication failed: Invalid token type") 112 | } 113 | 114 | internal suspend fun verifyTokenRevocation(token: String, userId: Int) { 115 | if (refreshTokensDao.exists(userId, token)) throw UnauthorizedActivityException("Authentication failed: Token has been revoked") 116 | } 117 | 118 | internal fun verifyPasswordOrThrowException(password: String, user: User) { 119 | user.password?.let { 120 | if (!passwordEncryption.validatePassword(password, it)) 121 | throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 122 | }?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 123 | } 124 | 125 | internal suspend fun storeToken(token: String) { 126 | val simpleDateFormat = SimpleDateFormat("'Date: 'yyyy-MM-dd' Time: 'HH:mm:ss") 127 | val expirationTime = tokenProvider.getTokenExpiration(token) 128 | val convertedExpirationTime = simpleDateFormat.format(expirationTime) 129 | 130 | try { 131 | tokenProvider.verifyToken(token)?.let { userId -> 132 | userDao.findByID(userId)?.let { 133 | refreshTokensDao.store( 134 | it.id, 135 | token, 136 | convertedExpirationTime 137 | ) 138 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 139 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 140 | } catch (uae: UnauthorizedActivityException) { 141 | AuthResponse.unauthorized(uae.message) 142 | } 143 | } 144 | 145 | internal suspend fun deleteExpiredTokens(userId: Int, currentTime: String) { 146 | refreshTokensDao.getAllById(userId).let { tokens -> 147 | tokens.forEach { 148 | if (it.expirationTime < currentTime) { 149 | refreshTokensDao.deleteById(it.id) 150 | } 151 | } 152 | } 153 | } 154 | 155 | internal fun validateCreateBlogFields(blogId: Int?, title: String, description: String, creationTime: String) { 156 | val message = when { 157 | blogId == null -> "Blog id should not be null or empty" 158 | title.count() < 3 -> "Title must be at least 3 characters" 159 | description.count() < 7 -> "Description must be at least 8 characters" 160 | creationTime.isBlank() -> "Creation time must not be blank" 161 | else -> return 162 | } 163 | throw BadRequestException(message) 164 | } 165 | 166 | internal fun validateUpdateBlogFields(blogId: Int?, title: String, description: String) { 167 | val message = when { 168 | blogId == null -> "Blog id should not be null or empty" 169 | title.count() < 3 -> "Title must be at least 3 characters" 170 | description.count() < 7 -> "Description must be at least 8 characters" 171 | else -> return 172 | } 173 | throw BadRequestException(message) 174 | } 175 | 176 | internal suspend fun verifyEmail(email: String) { 177 | if (!userDao.isEmailAvailable(email)) { 178 | throw BadRequestException("Authentication failed: Email is already taken") 179 | } 180 | } 181 | 182 | internal fun getEncryptedPassword(password: String): String { 183 | return passwordEncryption.encryptPassword(password) 184 | } 185 | 186 | internal fun getConvertedTokenExpirationTime(token: String): String { 187 | val expirationTime = tokenProvider.getTokenExpiration(token) 188 | return simpleDateFormat.format(expirationTime) 189 | } 190 | 191 | internal fun getConvertedCurrentTime(): String = simpleDateFormat.format((System.currentTimeMillis())) 192 | 193 | internal fun getTokenType(token: String): String { 194 | return tokenProvider.verifyTokenType(token) 195 | } 196 | 197 | internal fun checkPageNumber(page: Int, blogs: List>) { 198 | if (!(page > 0 && page <= blogs.size)) throw BadRequestException("Invalid page") 199 | } 200 | 201 | internal fun calculatePage( 202 | blogs: List>, 203 | page: Int 204 | ): Map { 205 | val previous = if (page == 1) null else page - 1 206 | val next = if (page == blogs.size) null else page + 1 207 | return mapOf( 208 | "previous" to previous, 209 | "next" to next 210 | ) 211 | } 212 | 213 | internal fun provideBlogs( 214 | blogs: List>, 215 | page: Int 216 | ): List { 217 | return blogs[page - 1] 218 | } 219 | 220 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/controller/auth/AuthController.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.controller.auth 2 | 3 | import com.auth0.jwt.exceptions.JWTDecodeException 4 | import com.auth0.jwt.exceptions.SignatureVerificationException 5 | import com.auth0.jwt.exceptions.TokenExpiredException 6 | import dev.zlagi.application.auth.TokenProvider 7 | import dev.zlagi.application.auth.firebase.FirebaseUserPrincipal 8 | import dev.zlagi.application.auth.principal.UserPrincipal 9 | import dev.zlagi.application.controller.BaseController 10 | import dev.zlagi.application.exception.BadRequestException 11 | import dev.zlagi.application.exception.UnauthorizedActivityException 12 | import dev.zlagi.application.model.request.* 13 | import dev.zlagi.application.model.response.AccountResponse 14 | import dev.zlagi.application.model.response.AuthResponse 15 | import dev.zlagi.application.model.response.GeneralResponse 16 | import dev.zlagi.application.model.response.Response 17 | import dev.zlagi.data.dao.TokenDao 18 | import dev.zlagi.data.dao.UserDao 19 | import io.ktor.application.* 20 | import io.ktor.auth.* 21 | import io.ktor.http.* 22 | import org.apache.commons.mail.DefaultAuthenticator 23 | import org.apache.commons.mail.SimpleEmail 24 | import org.koin.core.component.KoinComponent 25 | import org.koin.core.component.inject 26 | 27 | class DefaultAuthController : BaseController(), AuthController, KoinComponent { 28 | 29 | private val userDao by inject() 30 | private val refreshTokensDao by inject() 31 | private val tokenProvider by inject() 32 | 33 | override suspend fun idpAuthentication( 34 | idpAuthenticationRequest: IdpAuthenticationRequest, 35 | ctx: ApplicationCall 36 | ): Response { 37 | return try { 38 | val userEmail = ctx.principal()?.email 39 | userDao.findByEmail(userEmail!!)?.let { user -> 40 | val tokens = tokenProvider.createTokens(user) 41 | AuthResponse.success( 42 | "Sign in successfully", 43 | tokens.access_token, 44 | tokens.refresh_token 45 | ) 46 | } ?: userDao.storeUser(userEmail, idpAuthenticationRequest.username, null).let { 47 | val tokens = tokenProvider.createTokens(it) 48 | AuthResponse.success( 49 | "Sign up successfully", 50 | tokens.access_token, 51 | tokens.refresh_token, 52 | ) 53 | } 54 | } catch (e: BadRequestException) { 55 | GeneralResponse.failed(e.message) 56 | } 57 | } 58 | 59 | override suspend fun signIn(signInRequest: SignInRequest): Response { 60 | return try { 61 | validateSignInFieldsOrThrowException(signInRequest) 62 | userDao.findByEmail(signInRequest.email)?.let { 63 | verifyPasswordOrThrowException(signInRequest.password, it) 64 | val tokens = tokenProvider.createTokens(it) 65 | AuthResponse.success( 66 | "Sign in successfully", 67 | tokens.access_token, 68 | tokens.refresh_token 69 | ) 70 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 71 | } catch (e: BadRequestException) { 72 | GeneralResponse.failed(e.message) 73 | } catch (e: UnauthorizedActivityException) { 74 | GeneralResponse.unauthorized(e.message) 75 | } 76 | } 77 | 78 | override suspend fun signUp(signUpRequest: SignUpRequest): Response { 79 | return try { 80 | validateSignUpFieldsOrThrowException(signUpRequest) 81 | verifyEmail(signUpRequest.email) 82 | val encryptedPassword = getEncryptedPassword(signUpRequest.password) 83 | val user = userDao.storeUser(signUpRequest.email, signUpRequest.username, encryptedPassword) 84 | val tokens = tokenProvider.createTokens(user) 85 | AuthResponse.success( 86 | "Sign up successfully", 87 | tokens.access_token, 88 | tokens.refresh_token 89 | ) 90 | } catch (e: BadRequestException) { 91 | GeneralResponse.failed(e.message) 92 | } 93 | } 94 | 95 | override suspend fun refreshToken(refreshTokenRequest: RefreshTokenRequest): Response { 96 | return try { 97 | val token = refreshTokenRequest.token 98 | val expirationTime = getConvertedTokenExpirationTime(token) 99 | val tokenType = getTokenType(token) 100 | validateRefreshTokenFieldsOrThrowException(token) 101 | tokenProvider.verifyToken(token)?.let { userId -> 102 | validateRefreshTokenType(tokenType) 103 | verifyTokenRevocation(token, userId) 104 | deleteExpiredTokens(userId, getConvertedCurrentTime()) 105 | userDao.findByID(userId)?.let { 106 | val tokens = tokenProvider.createTokens(it) 107 | refreshTokensDao.store(userId, token, expirationTime) 108 | AuthResponse.success( 109 | "Tokens updated", 110 | tokens.access_token, 111 | tokens.refresh_token, 112 | ) 113 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 114 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 115 | } catch (e: TokenExpiredException) { 116 | GeneralResponse.failed("Authentication failed: Refresh token expired") 117 | } catch (e: SignatureVerificationException) { 118 | GeneralResponse.failed("Authentication failed: Failed to parse Refresh token") 119 | } catch (e: JWTDecodeException) { 120 | GeneralResponse.failed("Authentication failed: Failed to parse Refresh token") 121 | } catch (e: BadRequestException) { 122 | GeneralResponse.failed(e.message) 123 | } catch (e: UnauthorizedActivityException) { 124 | GeneralResponse.unauthorized(e.message) 125 | } 126 | } 127 | 128 | override suspend fun revokeToken(revokeTokenRequest: RevokeTokenRequest): Response { 129 | return try { 130 | val token = revokeTokenRequest.token 131 | validateSignOutFieldsOrThrowException(token) 132 | tokenProvider.verifyToken(token)?.let { userId -> 133 | val tokenType = getTokenType(token) 134 | validateRefreshTokenType(tokenType) 135 | verifyTokenRevocation(token, userId) 136 | deleteExpiredTokens(userId, getConvertedCurrentTime()) 137 | userDao.findByID(userId)?.let { 138 | storeToken(token) 139 | GeneralResponse.success( 140 | "Sign out successfully" 141 | ) 142 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 143 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 144 | } catch (e: TokenExpiredException) { 145 | GeneralResponse.success("Revocation success: Refresh token already expired") 146 | } catch (e: SignatureVerificationException) { 147 | GeneralResponse.success("Revocation failed: Failed to parse Refresh token") 148 | } catch (e: JWTDecodeException) { 149 | GeneralResponse.success("Revocation failed: Failed to parse Refresh token") 150 | } catch (e: BadRequestException) { 151 | GeneralResponse.failed(e.message) 152 | } catch (e: UnauthorizedActivityException) { 153 | GeneralResponse.unauthorized(e.message) 154 | } 155 | } 156 | 157 | override suspend fun getAccountById(ctx: ApplicationCall): Response { 158 | return try { 159 | val userId = ctx.principal()?.user?.id 160 | userDao.findByID(userId!!)?.let { 161 | AccountResponse.success("User found", it.id, it.username, it.email) 162 | } ?: throw UnauthorizedActivityException("User do not exist") 163 | } catch (e: UnauthorizedActivityException) { 164 | GeneralResponse.notFound(e.message) 165 | } 166 | } 167 | 168 | override suspend fun updateAccountPassword( 169 | updatePasswordRequest: UpdatePasswordRequest, 170 | ctx: ApplicationCall 171 | ): Response { 172 | return try { 173 | val userId = ctx.principal()?.user?.id 174 | val encryptedPassword = getEncryptedPassword(updatePasswordRequest.currentPassword) 175 | validateUpdatePasswordFieldsOrThrowException(updatePasswordRequest) 176 | userDao.findByID(userId!!)?.let { user -> 177 | verifyPasswordOrThrowException(updatePasswordRequest.currentPassword, user) 178 | userDao.updatePassword(user.id, encryptedPassword) 179 | GeneralResponse.success( 180 | "Password updated", 181 | ) 182 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 183 | } catch (e: BadRequestException) { 184 | GeneralResponse.failed(e.message) 185 | } catch (e: UnauthorizedActivityException) { 186 | GeneralResponse.failed(e.message) 187 | } 188 | } 189 | 190 | override suspend fun resetPassword(userEmail: String): Response { 191 | return try { 192 | validateResetPasswordFieldsOrThrowException(userEmail) 193 | userDao.findByEmail(userEmail)?.let { 194 | val token = tokenProvider.createTokens(it) 195 | val email = SimpleEmail() 196 | email.hostName = "smtp-mail.outlook.com" 197 | email.setSmtpPort(587) 198 | email.setAuthenticator( 199 | DefaultAuthenticator( 200 | "", 201 | "" 202 | ) 203 | ) 204 | email.isStartTLSEnabled = true 205 | email.setFrom("") 206 | email.subject = "Complete Password Reset!" 207 | email.setMsg( 208 | "To complete the password reset process, " + 209 | "please click here: \n https://blogfy-server.herokuapp.com/auth/confirm-reset-password?token=${token.access_token}" 210 | ) 211 | email.addTo("") 212 | email.send() 213 | GeneralResponse.success( 214 | "Request to reset password received. Check your inbox for the reset link.", 215 | ) 216 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 217 | } catch (e: BadRequestException) { 218 | GeneralResponse.failed(e.message) 219 | } catch (e: UnauthorizedActivityException) { 220 | GeneralResponse.unauthorized(e.message) 221 | } 222 | } 223 | 224 | override suspend fun confirmPasswordReset( 225 | tokenParameters: Parameters, 226 | updatePasswordRequest: UpdatePasswordRequest 227 | ): Response { 228 | return try { 229 | val token = tokenParameters["token"] 230 | validateTokenParametersOrThrowException(token) 231 | tokenProvider.verifyToken(token!!)?.let { userId -> 232 | validateUpdatePasswordFieldsOrThrowException(updatePasswordRequest) 233 | val encryptedPassword = getEncryptedPassword(updatePasswordRequest.currentPassword) 234 | verifyTokenRevocation(token, userId) 235 | validateAccessTokenType(getTokenType(token)) 236 | userDao.findByID(userId)?.let { user -> 237 | verifyPasswordOrThrowException(updatePasswordRequest.currentPassword, user) 238 | storeToken(token) 239 | userDao.updatePassword(userId, encryptedPassword) 240 | GeneralResponse.success( 241 | "Password updated" 242 | ) 243 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 244 | } ?: throw UnauthorizedActivityException("Authentication failed: Invalid credentials") 245 | } catch (e: TokenExpiredException) { 246 | GeneralResponse.failed("Reset link has been revoked") 247 | } catch (e: SignatureVerificationException) { 248 | GeneralResponse.failed("Authentication failed: Failed to parse token") 249 | } catch (e: JWTDecodeException) { 250 | GeneralResponse.failed("Authentication failed: Failed to parse token") 251 | } catch (e: BadRequestException) { 252 | GeneralResponse.failed(e.message) 253 | } catch (e: UnauthorizedActivityException) { 254 | GeneralResponse.unauthorized(e.message) 255 | } 256 | } 257 | } 258 | 259 | interface AuthController { 260 | suspend fun idpAuthentication( 261 | idpAuthenticationRequest: IdpAuthenticationRequest, 262 | ctx: ApplicationCall 263 | ): Response 264 | 265 | suspend fun signIn(signInRequest: SignInRequest): Response 266 | suspend fun signUp(signUpRequest: SignUpRequest): Response 267 | suspend fun refreshToken(refreshTokenRequest: RefreshTokenRequest): Response 268 | suspend fun revokeToken(revokeTokenRequest: RevokeTokenRequest): Response 269 | suspend fun getAccountById(ctx: ApplicationCall): Response 270 | suspend fun updateAccountPassword( 271 | updatePasswordRequest: UpdatePasswordRequest, 272 | ctx: ApplicationCall 273 | ): Response 274 | 275 | suspend fun resetPassword(userEmail: String): Response 276 | suspend fun confirmPasswordReset( 277 | tokenParameters: Parameters, 278 | updatePasswordRequest: UpdatePasswordRequest 279 | ): Response 280 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/controller/blog/BlogController.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.controller.blog 2 | 3 | import dev.zlagi.application.auth.principal.UserPrincipal 4 | import dev.zlagi.application.controller.BaseController 5 | import dev.zlagi.application.exception.BadRequestException 6 | import dev.zlagi.application.exception.BlogNotFoundException 7 | import dev.zlagi.application.model.request.BlogRequest 8 | import dev.zlagi.application.model.request.Notification 9 | import dev.zlagi.application.model.response.* 10 | import dev.zlagi.data.dao.BlogsDao 11 | import io.ktor.application.* 12 | import io.ktor.auth.* 13 | import io.ktor.client.* 14 | import io.ktor.client.request.* 15 | import io.ktor.http.* 16 | import org.jetbrains.exposed.dao.exceptions.EntityNotFoundException 17 | import org.koin.core.component.KoinComponent 18 | import org.koin.core.component.inject 19 | 20 | class DefaultBlogController : BaseController(), BlogController, KoinComponent { 21 | 22 | private val blogDao by inject() 23 | 24 | override suspend fun getBlogsByQuery(parameters: Parameters, ctx: ApplicationCall): Response { 25 | return try { 26 | val header = ctx.request.headers["Authorization"] 27 | header?.replace("Bearer ", "")?.let { token -> 28 | validateAccessTokenType(getTokenType(token)) 29 | } 30 | val page = parameters["page"]?.toInt() ?: 1 31 | val limit = parameters["limit"]?.toInt() ?: 10 32 | val search = parameters["search_query"] ?: "" 33 | val blogs = blogDao.searchByQuery(search) 34 | val windowedBlogs = blogs.windowed( 35 | size = limit, 36 | step = limit, 37 | partialWindows = true 38 | ) 39 | checkPageNumber(page, windowedBlogs) 40 | val paginatedBlogs = provideBlogs(windowedBlogs, page) 41 | BlogsResponse.success( 42 | Pagination( 43 | blogs.size, page, windowedBlogs.size, Links( 44 | calculatePage(windowedBlogs, page)["previous"], 45 | calculatePage(windowedBlogs, page)["next"] 46 | ) 47 | ), 48 | paginatedBlogs.map { 49 | BlogDomainModel( 50 | it.id, it.username, it.title, it.description, it.created, it.updated 51 | ) 52 | }, 53 | "Blogs found" 54 | ) 55 | } catch (e: BadRequestException) { 56 | GeneralResponse.success(e.message) 57 | } 58 | } 59 | 60 | override suspend fun sendNotification(httpClient: HttpClient, apiKey: String, notification: Notification): Response { 61 | return try { 62 | httpClient.post{ 63 | url(BlogController.NOTIFICATIONS) 64 | contentType(ContentType.Application.Json) 65 | header("Authorization", "Basic $apiKey") 66 | body = notification 67 | } 68 | GeneralResponse.success("Notification sent") 69 | } catch (e: Exception) { 70 | e.printStackTrace() 71 | GeneralResponse.failed("Error occurred") 72 | } 73 | } 74 | 75 | override suspend fun storeBlog(blogRequest: BlogRequest, ctx: ApplicationCall): Response { 76 | return try { 77 | val header = ctx.request.headers["Authorization"] 78 | header?.replace("Bearer ", "")?.let { token -> 79 | validateAccessTokenType(getTokenType(token)) 80 | } 81 | val userId = ctx.principal()?.user?.id 82 | val username = ctx.principal()?.user?.username 83 | validateCreateBlogFields(0, blogRequest.title, blogRequest.description, blogRequest.creationTime) 84 | val blog = 85 | blogDao.store( 86 | userId!!, 87 | username!!, 88 | blogRequest.title, 89 | blogRequest.description, 90 | blogRequest.creationTime, 91 | null 92 | ) 93 | BlogResponse.success( 94 | "Created", 95 | BlogDomainModel.fromData(blog) 96 | ) 97 | } catch (e: BadRequestException) { 98 | GeneralResponse.failed(e.message) 99 | } 100 | } 101 | 102 | override suspend fun updateBlog( 103 | blogRequest: BlogRequest, 104 | ctx: ApplicationCall 105 | ): Response { 106 | return try { 107 | val blogId = ctx.parameters["blogId"]?.toInt() 108 | val header = ctx.request.headers["Authorization"] 109 | header?.replace("Bearer ", "")?.let { token -> 110 | validateAccessTokenType(getTokenType(token)) 111 | } 112 | val updateTime = blogRequest.creationTime.ifEmpty { null } 113 | validateUpdateBlogFields(blogId!!, blogRequest.title, blogRequest.description) 114 | blogDao.update(blogId, blogRequest.title, blogRequest.description, updateTime).let { 115 | BlogResponse.success( 116 | "Updated", 117 | BlogDomainModel.fromData(it) 118 | ) 119 | } 120 | } catch (e: BadRequestException) { 121 | GeneralResponse.failed(e.message) 122 | } catch (e: IllegalArgumentException) { 123 | GeneralResponse.failed(e.message!!) 124 | } catch (e: EntityNotFoundException) { 125 | GeneralResponse.notFound("Blog not exist with ID '${ctx.parameters["blogId"]?.toInt()}'") 126 | } 127 | } 128 | 129 | override suspend fun deleteBlog(ctx: ApplicationCall): Response { 130 | return try { 131 | val blogId = ctx.parameters["blogId"]?.toInt() 132 | val header = ctx.request.headers["Authorization"] 133 | header?.replace("Bearer ", "")?.let { token -> 134 | validateAccessTokenType(getTokenType(token)) 135 | } 136 | if (!blogDao.exists(blogId!!)) { 137 | throw BlogNotFoundException("Blog not exist with ID '$blogId'") 138 | } 139 | if (blogDao.deleteById(blogId)) { 140 | GeneralResponse.success( 141 | "Deleted" 142 | ) 143 | } else { 144 | GeneralResponse.failed( 145 | "Error occured $blogId", 146 | ) 147 | } 148 | } catch (e: BadRequestException) { 149 | GeneralResponse.failed(e.message) 150 | } catch (e: BlogNotFoundException) { 151 | GeneralResponse.notFound(e.message) 152 | } catch (e: IllegalArgumentException) { 153 | GeneralResponse.failed(e.message!!) 154 | } 155 | } 156 | 157 | override suspend fun checkBlogAuthor(ctx: ApplicationCall): Response { 158 | return try { 159 | val blogId = ctx.parameters["blogId"]?.toInt() 160 | val userId = ctx.principal()?.user?.id 161 | val header = ctx.request.headers["Authorization"] 162 | header?.replace("Bearer ", "")?.let { token -> 163 | validateAccessTokenType(getTokenType(token)) 164 | } 165 | if (blogDao.isBlogAuthor(blogId!!, userId!!)) { 166 | GeneralResponse.success( 167 | "You have permission to edit that" 168 | ) 169 | } else { 170 | GeneralResponse.success( 171 | "You don't have permission to edit that" 172 | ) 173 | } 174 | } catch (e: BadRequestException) { 175 | GeneralResponse.failed(e.message) 176 | } catch (e: BlogNotFoundException) { 177 | GeneralResponse.notFound(e.message) 178 | } catch (e: IllegalArgumentException) { 179 | GeneralResponse.failed(e.message!!) 180 | } 181 | } 182 | } 183 | 184 | interface BlogController { 185 | suspend fun getBlogsByQuery(parameters: Parameters, ctx: ApplicationCall): Response 186 | suspend fun sendNotification(httpClient: HttpClient, apiKey: String, notification: Notification): Response 187 | suspend fun storeBlog(blogRequest: BlogRequest, ctx: ApplicationCall): Response 188 | suspend fun updateBlog(blogRequest: BlogRequest, ctx: ApplicationCall): Response 189 | suspend fun deleteBlog(ctx: ApplicationCall): Response 190 | suspend fun checkBlogAuthor(ctx: ApplicationCall): Response 191 | 192 | companion object { 193 | const val ONESIGNAL_APP_ID = "6679eba8-ba98-43da-ba87-9a9c7457bd33" 194 | const val NOTIFICATIONS = "https://onesignal.com/api/v1/notifications" 195 | } 196 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/controller/di/ControllerModule.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.controller.di 2 | 3 | import dev.zlagi.application.controller.auth.AuthController 4 | import dev.zlagi.application.controller.auth.DefaultAuthController 5 | import dev.zlagi.application.controller.blog.BlogController 6 | import dev.zlagi.application.controller.blog.DefaultBlogController 7 | import org.koin.dsl.module 8 | 9 | object ControllerModule { 10 | val koinBeans = module { 11 | single { DefaultAuthController() } 12 | single { DefaultBlogController() } 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/exception/ErrorExceptions.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.exception 2 | 3 | class BlogNotFoundException(override val message: String) : Exception(message) 4 | 5 | class BadRequestException(override val message: String) : Exception(message) 6 | 7 | class UnauthorizedActivityException(override val message: String) : Exception(message) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/BlogRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class BlogRequest( 7 | val title: String, 8 | val description: String, 9 | val creationTime: String 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/IdpAuthenticationRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class IdpAuthenticationRequest( 7 | val username: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/Notification.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class Notification( 8 | // @SerialName("include_external_user_ids") 9 | // val includeExternalUserIds: List, 10 | @SerialName("included_segments") 11 | val includedSegments: List, 12 | val contents: NotificationMessage, 13 | val headings: NotificationMessage, 14 | @SerialName("app_id") 15 | val appId: String, 16 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/NotificationMessage.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class NotificationMessage( 7 | val en: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/NotificationRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class NotificationRequest( 7 | val title: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/RefreshTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class RefreshTokenRequest( 7 | val token: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/ResetPasswordRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ResetPasswordRequest( 7 | val email: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/RevokeTokenRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class RevokeTokenRequest( 7 | val token: String 8 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/SignInRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SignInRequest( 7 | val email: String, 8 | val password: String 9 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/SignUpRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class SignUpRequest( 7 | val email: String, 8 | val username: String, 9 | val password: String, 10 | val confirmPassword: String 11 | ) 12 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/request/UpdatePasswordRequest.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.request 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class UpdatePasswordRequest( 7 | val currentPassword: String, 8 | val newPassword: String, 9 | val confirmNewPassword: String 10 | ) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/AccountResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AccountResponse( 7 | override val status: State, 8 | override val message: String, 9 | val id: Int? = null, 10 | val username: String? = null, 11 | val email: String? = null 12 | ) : Response { 13 | companion object { 14 | 15 | fun unauthorized(message: String) = AccountResponse( 16 | State.UNAUTHORIZED, 17 | message 18 | ) 19 | 20 | fun failed(message: String) = AccountResponse( 21 | State.FAILED, 22 | message 23 | ) 24 | 25 | fun notFound(message: String) = AccountResponse( 26 | State.NOT_FOUND, 27 | message 28 | ) 29 | 30 | fun success(message: String, userId: Int, email: String, username: String) = AccountResponse( 31 | State.SUCCESS, 32 | message, 33 | userId, 34 | email, 35 | username 36 | ) 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/AuthResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class AuthResponse( 7 | override val status: State, 8 | override val message: String, 9 | val access_token: String? = null, 10 | val refresh_token: String? = null 11 | ) : Response { 12 | 13 | companion object { 14 | 15 | fun failed(message: String) = AuthResponse( 16 | State.FAILED, 17 | message 18 | ) 19 | 20 | fun unauthorized(message: String) = AuthResponse( 21 | State.UNAUTHORIZED, 22 | message 23 | ) 24 | 25 | fun success(message: String) = AuthResponse( 26 | State.SUCCESS, 27 | message 28 | ) 29 | 30 | fun success(message: String, accessToken: String?, refreshToken: String?) = AuthResponse( 31 | State.SUCCESS, 32 | message, 33 | accessToken, 34 | refreshToken 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/BlogResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import dev.zlagi.data.model.BlogDataModel 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class BlogDomainModel( 8 | val pk: Int, 9 | val username: String, 10 | val title: String, 11 | val description: String, 12 | val created: String, 13 | val updated: String? 14 | ) { 15 | companion object { 16 | fun fromData(entity: BlogDataModel) = 17 | BlogDomainModel( 18 | entity.id, 19 | entity.username, 20 | entity.title, 21 | entity.description, 22 | entity.created, 23 | entity.updated 24 | ) 25 | } 26 | } 27 | 28 | @Serializable 29 | data class BlogResponse( 30 | override val status: State, 31 | override val message: String, 32 | val pk: Int = -1, 33 | val title: String = "", 34 | val description: String = "", 35 | val created: String = "", 36 | val updated: String? = "", 37 | val username: String = "" 38 | ) : Response { 39 | companion object { 40 | 41 | fun failed(message: String) = BlogResponse( 42 | State.FAILED, 43 | message 44 | ) 45 | 46 | fun success(message: String, blog: BlogDomainModel) = BlogResponse( 47 | State.SUCCESS, 48 | message, 49 | blog.pk, 50 | blog.title, 51 | blog.description, 52 | blog.created, 53 | blog.updated, 54 | blog.username 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/BlogsResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class BlogsResponse( 7 | override val status: State, 8 | override val message: String, 9 | val pagination: Pagination? = null, 10 | val results: List = emptyList() 11 | ) : Response { 12 | companion object { 13 | 14 | fun failed(message: String) = BlogsResponse( 15 | State.FAILED, 16 | message 17 | ) 18 | 19 | fun notFound(message: String) = BlogsResponse( 20 | State.NOT_FOUND, 21 | message 22 | ) 23 | 24 | fun success(pagination: Pagination?, blogs: List, message: String) = 25 | BlogsResponse( 26 | State.SUCCESS, 27 | message, 28 | pagination, 29 | blogs, 30 | ) 31 | } 32 | } 33 | 34 | @Serializable 35 | data class Pagination( 36 | val total_count: Int, 37 | val current_page: Int, 38 | val total_pages: Int, 39 | val _links: Links 40 | ) 41 | 42 | @Serializable 43 | data class Links( 44 | val previous: Int? = null, 45 | val next: Int? = null 46 | ) 47 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/GeneralResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class GeneralResponse( 7 | override val status: State, 8 | override val message: String 9 | ) : Response { 10 | companion object { 11 | 12 | fun unauthorized(message: String) = GeneralResponse( 13 | State.UNAUTHORIZED, 14 | message 15 | ) 16 | 17 | fun failed(message: String) = GeneralResponse( 18 | State.FAILED, 19 | message 20 | ) 21 | 22 | fun notFound(message: String) = GeneralResponse( 23 | State.NOT_FOUND, 24 | message 25 | ) 26 | 27 | fun success(message: String) = GeneralResponse( 28 | State.SUCCESS, 29 | message 30 | ) 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/HttpResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import io.ktor.http.* 4 | 5 | /** 6 | * Represents HTTP response which will be exposed via API. 7 | */ 8 | sealed class HttpResponse { 9 | abstract val body: T 10 | abstract val code: HttpStatusCode 11 | 12 | data class Ok(override val body: T) : HttpResponse() { 13 | override val code: HttpStatusCode = HttpStatusCode.OK 14 | } 15 | 16 | data class NotFound(override val body: T) : HttpResponse() { 17 | override val code: HttpStatusCode = HttpStatusCode.NotFound 18 | } 19 | 20 | data class BadRequest(override val body: T) : HttpResponse() { 21 | override val code: HttpStatusCode = HttpStatusCode.BadRequest 22 | } 23 | 24 | data class Unauthorized(override val body: T) : HttpResponse() { 25 | override val code: HttpStatusCode = HttpStatusCode.Unauthorized 26 | } 27 | 28 | companion object { 29 | fun ok(response: T) = Ok(body = response) 30 | 31 | fun notFound(response: T) = NotFound(body = response) 32 | 33 | fun badRequest(response: T) = BadRequest(body = response) 34 | 35 | fun unauth(response: T) = Unauthorized(body = response) 36 | } 37 | } 38 | 39 | /** 40 | * Generates [HttpResponse] from [Response]. 41 | */ 42 | fun generateHttpResponse(response: Response): HttpResponse { 43 | return when (response.status) { 44 | State.SUCCESS -> HttpResponse.ok(response) 45 | State.NOT_FOUND -> HttpResponse.notFound(response) 46 | State.FAILED -> HttpResponse.badRequest(response) 47 | State.UNAUTHORIZED -> HttpResponse.unauth(response) 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/Response.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | /** 4 | * Response model to expose in API response 5 | */ 6 | interface Response { 7 | val status: State 8 | val message: String 9 | } 10 | 11 | /** 12 | * HTTP Response Status. Used for evaluation of [HttpResponse] type. 13 | */ 14 | enum class State { 15 | SUCCESS, NOT_FOUND, FAILED, UNAUTHORIZED 16 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/model/response/TokenResponse.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.model.response 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class TokenResponse(val access_token: String, val refresh_token: String) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/plugins/Koin.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.plugins 2 | 3 | import com.auth0.jwt.interfaces.JWTVerifier 4 | import dev.zlagi.application.auth.JWTController 5 | import dev.zlagi.application.auth.PasswordEncryptor 6 | import dev.zlagi.application.auth.PasswordEncryptorContract 7 | import dev.zlagi.application.auth.TokenProvider 8 | import dev.zlagi.application.controller.di.ControllerModule 9 | import dev.zlagi.data.di.DaoModule 10 | import io.ktor.application.* 11 | import org.koin.core.annotation.KoinReflectAPI 12 | import org.koin.dsl.module 13 | import org.koin.ktor.ext.Koin 14 | import org.koin.logger.slf4jLogger 15 | 16 | @KoinReflectAPI 17 | fun Application.configureKoin() { 18 | 19 | install(feature = Koin) { 20 | slf4jLogger(level = org.koin.core.logger.Level.ERROR) //This params are the workaround itself 21 | modules( 22 | module { 23 | single { JWTController.verifier } 24 | single { JWTController } 25 | single { PasswordEncryptor } 26 | }, 27 | DaoModule.koinBeans, 28 | ControllerModule.koinBeans 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/plugins/Monitoring.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.plugins 2 | 3 | import io.ktor.application.* 4 | import io.ktor.features.* 5 | import io.ktor.request.* 6 | import org.slf4j.event.Level 7 | 8 | fun Application.configureMonitoring() { 9 | install(CallLogging) { 10 | level = Level.INFO 11 | filter { call -> call.request.path().startsWith("/") } 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/plugins/Routing.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.plugins 2 | 3 | import dev.zlagi.application.router.authApi 4 | import dev.zlagi.application.router.blogApi 5 | import io.ktor.application.* 6 | import io.ktor.client.* 7 | import io.ktor.routing.* 8 | 9 | fun Application.configureRouting(httpClient: HttpClient, apiKey: String) { 10 | routing { 11 | authApi() 12 | blogApi(httpClient, apiKey) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/plugins/Security.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.plugins 2 | 3 | import com.auth0.jwt.exceptions.JWTDecodeException 4 | import com.auth0.jwt.exceptions.SignatureVerificationException 5 | import com.auth0.jwt.exceptions.TokenExpiredException 6 | import com.auth0.jwt.interfaces.JWTVerifier 7 | import dev.zlagi.application.auth.firebase.FirebaseConfig.configure 8 | import dev.zlagi.application.auth.firebase.firebase 9 | import dev.zlagi.application.auth.principal.UserPrincipal 10 | import dev.zlagi.application.model.response.GeneralResponse 11 | import dev.zlagi.data.dao.UserDao 12 | import io.ktor.application.* 13 | import io.ktor.auth.* 14 | import io.ktor.auth.jwt.* 15 | import io.ktor.http.* 16 | import io.ktor.response.* 17 | 18 | fun Application.configureSecurity( 19 | userDao: UserDao, 20 | jwtVerifier: JWTVerifier 21 | ) { 22 | 23 | install(Authentication) { 24 | firebase { configure() } 25 | 26 | jwt("jwt") { 27 | verifier(jwtVerifier) 28 | 29 | // https://stackoverflow.com/questions/62377411/how-do-i-get-access-to-errors-in-custom-ktor-jwt-challenge 30 | 31 | challenge { _, _ -> 32 | // get custom error message if error exists 33 | val header = call.request.headers["Authorization"] 34 | header?.let { 35 | if (it.isNotEmpty()) { 36 | try { 37 | if ((!it.contains("Bearer", true))) throw JWTDecodeException("") 38 | val jwt = it.replace("Bearer ", "") 39 | jwtVerifier.verify(jwt) 40 | "" 41 | } catch (e: TokenExpiredException) { 42 | call.respond( 43 | HttpStatusCode.Unauthorized, 44 | GeneralResponse.failed("Authentication failed: Access token expired") 45 | ) 46 | } catch (e: SignatureVerificationException) { 47 | call.respond( 48 | HttpStatusCode.BadRequest, 49 | GeneralResponse.failed("Authentication failed: Failed to parse Access token") 50 | ) 51 | } catch (e: JWTDecodeException) { 52 | call.respond( 53 | HttpStatusCode.BadRequest, 54 | GeneralResponse.failed("Authentication failed: Failed to parse Access token") 55 | ) 56 | } 57 | } else call.respond( 58 | HttpStatusCode.BadRequest, 59 | GeneralResponse.failed("Authentication failed: Access token not found") 60 | ) 61 | } ?: call.respond( 62 | HttpStatusCode.Unauthorized, GeneralResponse.unauthorized("Authentication failed: No authorization header found") 63 | ) 64 | GeneralResponse.unauthorized("Unauthorized") 65 | } 66 | 67 | validate { credential -> 68 | credential.payload.getClaim("userId").asInt()?.let { userId -> 69 | // do database query to find Principal subclass 70 | val user = userDao.findByID(userId) 71 | user?.let { 72 | UserPrincipal(it) 73 | } 74 | } 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/plugins/Serialization.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.plugins 2 | 3 | import io.ktor.application.* 4 | import io.ktor.features.* 5 | import io.ktor.serialization.* 6 | 7 | fun Application.configureSerialization() { 8 | install(ContentNegotiation) { 9 | json() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/router/AuthRouter.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.router 2 | 3 | import dev.zlagi.application.controller.auth.AuthController 4 | import dev.zlagi.application.model.request.* 5 | import dev.zlagi.application.model.response.generateHttpResponse 6 | import io.ktor.application.* 7 | import io.ktor.auth.* 8 | import io.ktor.request.* 9 | import io.ktor.response.* 10 | import io.ktor.routing.* 11 | import org.koin.ktor.ext.inject 12 | 13 | fun Route.authApi() { 14 | 15 | val authController by inject() 16 | 17 | route("/auth") { 18 | 19 | authenticate { 20 | 21 | route("/idp") { 22 | 23 | // Single endpoint for both signing and registration 24 | post("/google") { 25 | val idpAuthenticationRequest = call.receive() 26 | val idpAuthenticationResponse = authController.idpAuthentication(idpAuthenticationRequest, this.context) 27 | val response = generateHttpResponse(idpAuthenticationResponse) 28 | call.respond(response.code, response.body) 29 | } 30 | } 31 | } 32 | 33 | post("/signin") { 34 | val signInRequest = call.receive() 35 | val signInResponse = authController.signIn(signInRequest) 36 | val response = generateHttpResponse(signInResponse) 37 | call.respond(response.code, response.body) 38 | } 39 | 40 | post("/signup") { 41 | val signUpRequest = call.receive() 42 | val signUpResponse = authController.signUp(signUpRequest) 43 | val response = generateHttpResponse(signUpResponse) 44 | call.respond(response.code, response.body) 45 | } 46 | 47 | route("/token") { 48 | 49 | post("/refresh") { 50 | val refreshTokenRequest = call.receive() 51 | val refreshTokenResponse = 52 | authController.refreshToken(refreshTokenRequest) 53 | val response = generateHttpResponse(refreshTokenResponse) 54 | call.respond(response.code, response.body) 55 | } 56 | 57 | post("/revoke") { 58 | val revokeTokenRequest = call.receive() 59 | val revokeTokenResponse = 60 | authController.revokeToken(revokeTokenRequest) 61 | val response = generateHttpResponse(revokeTokenResponse) 62 | call.respond(response.code, response.body) 63 | } 64 | } 65 | 66 | route("/account") { 67 | 68 | authenticate("jwt") { 69 | 70 | get { 71 | val accountResponse = authController.getAccountById(this.context) 72 | val response = generateHttpResponse(accountResponse) 73 | call.respond(response.code, response.body) 74 | } 75 | 76 | put("/password") { 77 | val updatePasswordRequest = call.receive() 78 | val updatePasswordResponse = 79 | authController.updateAccountPassword( 80 | updatePasswordRequest, 81 | this.context 82 | ) 83 | val response = generateHttpResponse(updatePasswordResponse) 84 | call.respond(response.code, response.body) 85 | } 86 | } 87 | } 88 | 89 | // Not used with android client 90 | post("/reset-password") { 91 | val resetPasswordRequest = call.receive() 92 | val passwordResetResponse = 93 | authController.resetPassword(resetPasswordRequest.email) 94 | val response = generateHttpResponse(passwordResetResponse) 95 | call.respond(response.code, response.body) 96 | } 97 | 98 | // Not used with android client 99 | post("confirm-reset-password") { 100 | val tokenParameters = call.request.queryParameters 101 | val resetPasswordRequest = call.receive() 102 | val resetPasswordResponse = 103 | authController.confirmPasswordReset( 104 | tokenParameters, 105 | resetPasswordRequest 106 | ) 107 | val response = generateHttpResponse(resetPasswordResponse) 108 | call.respond(response.code, response.body) 109 | } 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/router/BlogRouter.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.router 2 | 3 | import dev.zlagi.application.auth.principal.UserPrincipal 4 | import dev.zlagi.application.controller.blog.BlogController 5 | import dev.zlagi.application.controller.blog.BlogController.Companion.ONESIGNAL_APP_ID 6 | import dev.zlagi.application.model.request.BlogRequest 7 | import dev.zlagi.application.model.request.Notification 8 | import dev.zlagi.application.model.request.NotificationMessage 9 | import dev.zlagi.application.model.response.generateHttpResponse 10 | import io.ktor.application.* 11 | import io.ktor.auth.* 12 | import io.ktor.client.* 13 | import io.ktor.request.* 14 | import io.ktor.response.* 15 | import io.ktor.routing.* 16 | import org.koin.ktor.ext.inject 17 | 18 | fun Route.blogApi(httpClient: HttpClient, apiKey: String) { 19 | 20 | val blogController by inject() 21 | 22 | authenticate("jwt") { 23 | 24 | route("/blog") { 25 | 26 | get("/list") { 27 | val getBlogsRequest = call.request.queryParameters 28 | val getBlogsResults = blogController.getBlogsByQuery(getBlogsRequest, this.context) 29 | val response = generateHttpResponse(getBlogsResults) 30 | call.respond(response.code, response.body) 31 | } 32 | 33 | post("/notification") { 34 | val username = call.principal()?.user?.username 35 | val notificationResponse = blogController.sendNotification(httpClient, apiKey, Notification( 36 | includedSegments = listOf("All"), 37 | headings = NotificationMessage(en = "Blogfy"), 38 | contents = NotificationMessage(en = "$username has published a new blog\uD83D\uDD25"), 39 | appId = ONESIGNAL_APP_ID 40 | )) 41 | val response = generateHttpResponse(notificationResponse) 42 | call.respond(response.code, response.body) 43 | } 44 | 45 | post { 46 | val createBlogRequest = call.receive() 47 | val createBlogResponse = blogController.storeBlog(createBlogRequest, this.context) 48 | val response = generateHttpResponse(createBlogResponse) 49 | call.respond(response.code, response.body) 50 | } 51 | 52 | put("/{blogId}") { 53 | val updateBlogRequest = call.receive() 54 | val updateBlogResponse = blogController.updateBlog(updateBlogRequest, this.context) 55 | val response = generateHttpResponse(updateBlogResponse) 56 | call.respond(response.code, response.body) 57 | } 58 | 59 | delete("/{blogId}") { 60 | val deleteBlogResponse = blogController.deleteBlog(this.context) 61 | val response = generateHttpResponse(deleteBlogResponse) 62 | call.respond(response.code, response.body) 63 | } 64 | 65 | get("{blogId}/is_author") { 66 | val checkAuthorResponse = blogController.checkBlogAuthor(this.context) 67 | val response = generateHttpResponse(checkAuthorResponse) 68 | call.respond(response.code, response.body) 69 | } 70 | } 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/application/utils/StringExt.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.application.utils 2 | 3 | const val MAIL_REGEX = ("^(([\\w-]+\\.)+[\\w-]+|([a-zA-Z]|[\\w-]{2,}))@" 4 | + "((([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\.([0-1]?" 5 | + "[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\." 6 | + "([0-1]?[0-9]{1,2}|25[0-5]|2[0-4][0-9])\\.([0-1]?" 7 | + "[0-9]{1,2}|25[0-5]|2[0-4][0-9]))|" 8 | + "([a-zA-Z]+[\\w-]+\\.)+[a-zA-Z]{2,4})$") 9 | 10 | fun String.isEmailValid(): Boolean = !this.isNullOrBlank() && Regex(MAIL_REGEX).matches(this) 11 | 12 | fun String.isAlphaNumeric() = matches("[a-zA-Z0-9]+".toRegex()) -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/dao/BlogsDao.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.dao 2 | 3 | import dev.zlagi.data.model.BlogDataModel 4 | 5 | interface BlogsDao { 6 | suspend fun store( 7 | userId: Int, 8 | username: String, 9 | title: String, 10 | description: String, 11 | created: String, 12 | updated: String? 13 | ): BlogDataModel 14 | 15 | suspend fun searchByQuery(query: String): List 16 | suspend fun update(blogId: Int, title: String, description: String, updated: String?): BlogDataModel 17 | suspend fun deleteById(blogId: Int): Boolean 18 | suspend fun isBlogAuthor(blogId: Int, userId: Int): Boolean 19 | suspend fun exists(blogId: Int): Boolean 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/dao/TokenDao.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.dao 2 | 3 | import dev.zlagi.data.model.Token 4 | 5 | interface TokenDao { 6 | suspend fun store( 7 | userId: Int, token: String, expirationTime: String 8 | ): String 9 | suspend fun getAllById(userId: Int): List 10 | suspend fun exists(userId: Int, token: String): Boolean 11 | suspend fun deleteById(tokenId: String): Boolean 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/dao/UserDao.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.dao 2 | 3 | import dev.zlagi.data.model.User 4 | 5 | interface UserDao { 6 | suspend fun storeUser(email: String, username: String, password: String?): User 7 | suspend fun findByID(userId: Int): User? 8 | suspend fun findByEmail(email: String): User? 9 | suspend fun isEmailAvailable(email: String): Boolean 10 | suspend fun updatePassword(userId: Int, password: String) 11 | } 12 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/database/DatabaseProvider.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("EXPERIMENTAL_IS_NOT_ENABLED") 2 | 3 | package dev.zlagi.data.database 4 | 5 | import com.zaxxer.hikari.HikariConfig 6 | import com.zaxxer.hikari.HikariDataSource 7 | import dev.zlagi.data.database.table.Blogs 8 | import dev.zlagi.data.database.table.Tokens 9 | import dev.zlagi.data.database.table.Users 10 | import kotlinx.coroutines.DelicateCoroutinesApi 11 | import kotlinx.coroutines.newFixedThreadPoolContext 12 | import org.jetbrains.exposed.sql.Database 13 | import org.jetbrains.exposed.sql.SchemaUtils.create 14 | import org.jetbrains.exposed.sql.transactions.transaction 15 | import org.koin.core.component.KoinComponent 16 | import java.net.URI 17 | import kotlin.coroutines.CoroutineContext 18 | 19 | @OptIn(DelicateCoroutinesApi::class) 20 | class DatabaseProvider : DatabaseProviderContract, KoinComponent { 21 | 22 | private val dispatcher: CoroutineContext 23 | 24 | init { 25 | dispatcher = newFixedThreadPoolContext(5, "database-pool") 26 | } 27 | 28 | override fun init() { 29 | Database.connect(hikari()) 30 | transaction { 31 | create(Users) 32 | create(Blogs) 33 | create(Tokens) 34 | } 35 | } 36 | 37 | /* private fun hikari(): HikariDataSource { 38 | HikariConfig().run { 39 | driverClassName = driverClass 40 | jdbcUrl = "jdbc:postgresql://${host}:${port}/${dbname}" 41 | username = user 42 | password = dbpassword 43 | isAutoCommit = false 44 | maximumPoolSize = 5 45 | transactionIsolation = "TRANSACTION_REPEATABLE_READ" 46 | validate() 47 | return HikariDataSource(this) 48 | } 49 | } 50 | 51 | companion object DatabaseConfig { 52 | const val driverClass = "org.postgresql.Driver" 53 | const val host = "localhost" 54 | const val port = 3500 55 | const val dbname = "blogfy" 56 | const val user = "postgres" 57 | const val dbpassword = "root" 58 | }*/ 59 | 60 | // For heroku deployement 61 | private fun hikari(): HikariDataSource { 62 | val config = HikariConfig() 63 | config.driverClassName = System.getenv("JDBC_DRIVER") 64 | config.isAutoCommit = false 65 | config.transactionIsolation = "TRANSACTION_REPEATABLE_READ" 66 | 67 | val uri = URI(System.getenv("DATABASE_URL")) 68 | val username = uri.userInfo.split(":").toTypedArray()[0] 69 | val password = uri.userInfo.split(":").toTypedArray()[1] 70 | 71 | config.jdbcUrl = 72 | "jdbc:postgresql://" + uri.host + ":" + uri.port + uri.path + "?sslmode=require" + "&user=$username&password=$password" 73 | 74 | 75 | config.validate() 76 | 77 | return HikariDataSource(config) 78 | } 79 | 80 | } 81 | 82 | interface DatabaseProviderContract { 83 | fun init() 84 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/database/table/Blogs.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.database.table 2 | 3 | import dev.zlagi.data.dao.BlogsDao 4 | import dev.zlagi.data.entity.EntityBlog 5 | import dev.zlagi.data.entity.EntityUser 6 | import dev.zlagi.data.model.BlogDataModel 7 | import kotlinx.coroutines.Dispatchers 8 | import org.jetbrains.exposed.dao.id.IntIdTable 9 | import org.jetbrains.exposed.sql.and 10 | import org.jetbrains.exposed.sql.or 11 | import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction 12 | 13 | object Blogs : IntIdTable(), BlogsDao { 14 | val user = reference("user", Users) 15 | var username = varchar("username", length = 128) 16 | var title = varchar("title", length = 128) 17 | var description = text("description") 18 | var created = varchar("created", length = 48) 19 | var updated = varchar("updated", length = 48).nullable() 20 | 21 | override suspend fun store( 22 | userId: Int, 23 | username: String, 24 | title: String, 25 | description: String, 26 | created: String, 27 | updated: String? 28 | ): BlogDataModel = 29 | newSuspendedTransaction(Dispatchers.IO) { 30 | EntityBlog.new { 31 | this.user = EntityUser[userId] 32 | this.username = username 33 | this.title = title 34 | this.description = description 35 | this.created = created 36 | this.updated = updated 37 | }.let { BlogDataModel.fromEntity(it) } 38 | } 39 | 40 | override suspend fun searchByQuery(query: String): List = newSuspendedTransaction(Dispatchers.IO) { 41 | EntityBlog.find { 42 | title regexp query or (description regexp query) 43 | }.sortedByDescending { it.id } 44 | .map { BlogDataModel.fromEntity(it) } 45 | } 46 | 47 | override suspend fun update(blogId: Int, title: String, description: String, updated: String?): BlogDataModel = 48 | newSuspendedTransaction(Dispatchers.IO) { 49 | EntityBlog[blogId].apply { 50 | this.title = title 51 | this.description = description 52 | updated?.let { 53 | this.updated = it 54 | } 55 | }.let { BlogDataModel.fromEntity(it) } 56 | } 57 | 58 | override suspend fun deleteById(blogId: Int): Boolean = 59 | newSuspendedTransaction(Dispatchers.IO) { 60 | val blog = EntityBlog.findById(blogId) 61 | blog?.run { 62 | delete() 63 | return@newSuspendedTransaction true 64 | } 65 | return@newSuspendedTransaction false 66 | } 67 | 68 | override suspend fun isBlogAuthor(blogId: Int, userId: Int): Boolean = 69 | newSuspendedTransaction(Dispatchers.IO) { 70 | EntityBlog.find { 71 | (Blogs.id eq blogId) and (user eq userId) 72 | }.firstOrNull() != null 73 | } 74 | 75 | override suspend fun exists(blogId: Int): Boolean = newSuspendedTransaction(Dispatchers.IO) { 76 | EntityBlog.findById(blogId) != null 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/database/table/Tokens.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.database.table 2 | 3 | import dev.zlagi.data.dao.TokenDao 4 | import dev.zlagi.data.entity.EntityToken 5 | import dev.zlagi.data.entity.EntityUser 6 | import dev.zlagi.data.model.Token 7 | import kotlinx.coroutines.Dispatchers 8 | import org.jetbrains.exposed.dao.id.UUIDTable 9 | import org.jetbrains.exposed.sql.and 10 | import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction 11 | import java.util.* 12 | 13 | object Tokens : UUIDTable(), TokenDao { 14 | val user = reference("user", Users) 15 | val token = varchar("token", 512) 16 | val expirationTime = varchar("expiration_time", 128) 17 | 18 | override suspend fun store(userId: Int, token: String, expirationTime: String): String = 19 | newSuspendedTransaction(Dispatchers.IO) { 20 | EntityToken.new { 21 | this.user = EntityUser[userId] 22 | this.token = token 23 | this.expirationTime = expirationTime 24 | }.id.value.toString() 25 | } 26 | 27 | override suspend fun getAllById(userId: Int): List = newSuspendedTransaction(Dispatchers.IO) { 28 | EntityToken.find { user eq userId } 29 | .sortedByDescending { it.id } 30 | .map { Token.fromEntity(it) } 31 | } 32 | 33 | override suspend fun exists(userId: Int, token: String): Boolean = newSuspendedTransaction(Dispatchers.IO) { 34 | EntityToken.find { 35 | ((Tokens.token eq token) and (user eq userId)) 36 | }.firstOrNull() != null 37 | } 38 | 39 | override suspend fun deleteById(tokenId: String): Boolean = 40 | newSuspendedTransaction(Dispatchers.IO) { 41 | val token = EntityToken.findById(UUID.fromString(tokenId)) 42 | token?.run { 43 | delete() 44 | true 45 | } 46 | false 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/database/table/Users.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.database.table 2 | 3 | import dev.zlagi.data.dao.UserDao 4 | import dev.zlagi.data.entity.EntityUser 5 | import dev.zlagi.data.model.User 6 | import kotlinx.coroutines.Dispatchers 7 | import org.jetbrains.exposed.dao.id.IntIdTable 8 | import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction 9 | 10 | object Users : IntIdTable(), UserDao { 11 | val email = varchar("email", length = 30).uniqueIndex() 12 | val username = varchar("username", length = 30) 13 | val password = text("password").nullable() 14 | 15 | override suspend fun storeUser(email: String, username: String, password: String?): User = 16 | newSuspendedTransaction(Dispatchers.IO) { 17 | EntityUser.new { 18 | this.email = email 19 | this.username = username 20 | this.password = password 21 | }.let { 22 | User.fromEntity(it) 23 | } 24 | } 25 | 26 | override suspend fun findByID(userId: Int): User? = 27 | newSuspendedTransaction(Dispatchers.IO) { 28 | EntityUser.findById(userId) 29 | }?.let { 30 | User.fromEntity(it) 31 | } 32 | 33 | override suspend fun findByEmail(email: String): User? = 34 | newSuspendedTransaction(Dispatchers.IO) { 35 | EntityUser.find { 36 | (Users.email eq email) 37 | }.firstOrNull() 38 | }?.let { User.fromEntity(it) } 39 | 40 | override suspend fun isEmailAvailable(email: String): Boolean { 41 | return newSuspendedTransaction(Dispatchers.IO) { 42 | EntityUser.find { Users.email eq email }.firstOrNull() 43 | } == null 44 | } 45 | 46 | override suspend fun updatePassword(userId: Int, password: String) { 47 | newSuspendedTransaction(Dispatchers.IO) { 48 | EntityUser[userId].apply { 49 | this.password = password 50 | }.id.value.toString() 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/di/DaoModule.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.di 2 | 3 | import dev.zlagi.data.dao.BlogsDao 4 | import dev.zlagi.data.dao.TokenDao 5 | import dev.zlagi.data.dao.UserDao 6 | import dev.zlagi.data.database.DatabaseProvider 7 | import dev.zlagi.data.database.DatabaseProviderContract 8 | import dev.zlagi.data.database.table.Blogs 9 | import dev.zlagi.data.database.table.Tokens 10 | import dev.zlagi.data.database.table.Users 11 | import org.koin.dsl.module 12 | 13 | object DaoModule { 14 | val koinBeans = module { 15 | single { Tokens } 16 | single { Users } 17 | single { Blogs } 18 | single { DatabaseProvider() } 19 | } 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/entity/EntityBlog.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.entity 2 | 3 | import dev.zlagi.data.database.table.Blogs 4 | import org.jetbrains.exposed.dao.IntEntity 5 | import org.jetbrains.exposed.dao.IntEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | 8 | class EntityBlog(id: EntityID) : IntEntity(id) { 9 | companion object : IntEntityClass(Blogs) 10 | 11 | var user by EntityUser referencedOn Blogs.user 12 | var username by Blogs.username 13 | var title by Blogs.title 14 | var description by Blogs.description 15 | var created by Blogs.created 16 | var updated by Blogs.updated 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/entity/EntityToken.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.entity 2 | 3 | import dev.zlagi.data.database.table.Tokens 4 | import org.jetbrains.exposed.dao.UUIDEntity 5 | import org.jetbrains.exposed.dao.UUIDEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | import java.util.* 8 | 9 | class EntityToken(id: EntityID) : UUIDEntity(id) { 10 | companion object : UUIDEntityClass(Tokens) 11 | 12 | var user by EntityUser referencedOn Tokens.user 13 | var token by Tokens.token 14 | var expirationTime by Tokens.expirationTime 15 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/entity/EntityUser.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.entity 2 | 3 | import dev.zlagi.data.database.table.Users 4 | import org.jetbrains.exposed.dao.IntEntity 5 | import org.jetbrains.exposed.dao.IntEntityClass 6 | import org.jetbrains.exposed.dao.id.EntityID 7 | 8 | class EntityUser(id: EntityID) : IntEntity(id) { 9 | companion object : IntEntityClass(Users) 10 | 11 | var email by Users.email 12 | var username by Users.username 13 | var password by Users.password 14 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/model/BlogDataModel.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.model 2 | 3 | import dev.zlagi.data.entity.EntityBlog 4 | 5 | data class BlogDataModel( 6 | val id: Int, 7 | val username: String, 8 | val title: String, 9 | val description: String, 10 | val created: String, 11 | val updated: String? 12 | ) { 13 | companion object { 14 | fun fromEntity(entity: EntityBlog) = 15 | BlogDataModel(entity.id.value, 16 | entity.username, 17 | entity.title, 18 | entity.description, 19 | entity.created, 20 | entity.updated) 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/model/Token.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.model 2 | 3 | import dev.zlagi.data.entity.EntityToken 4 | 5 | data class Token( 6 | val id: String, 7 | val token: String, 8 | val expirationTime: String 9 | ) { 10 | companion object { 11 | fun fromEntity(entity: EntityToken) = 12 | Token( 13 | entity.id.value.toString(), 14 | entity.token, 15 | entity.expirationTime 16 | ) 17 | } 18 | } -------------------------------------------------------------------------------- /src/main/kotlin/dev/zlagi/data/model/User.kt: -------------------------------------------------------------------------------- 1 | package dev.zlagi.data.model 2 | 3 | import dev.zlagi.data.entity.EntityUser 4 | 5 | data class User( 6 | val id: Int, 7 | val email: String, 8 | val username: String, 9 | val password: String? 10 | ) { 11 | companion object { 12 | fun fromEntity(entity: EntityUser) = User(entity.id.value, entity.email, entity.username, entity.password) 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | ktor { 2 | deployment { 3 | port = 8080 4 | port = ${?PORT} 5 | } 6 | application { 7 | modules = [ dev.zlagi.application.ApplicationKt.module ] 8 | } 9 | } 10 | 11 | onesignal { 12 | apiKey = "ZWZlYzczZTQtMGEyYi00MmZmLTkzYWEtNGYxMWU4MjAwYzZj" 13 | } 14 | 15 | jwt { 16 | domain = "https://jwt-provider-domain/" 17 | audience = "jwt-audience" 18 | realm = "Blogfy" 19 | } 20 | -------------------------------------------------------------------------------- /src/main/resources/ktor-firebase-auth-firebase-adminsdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "blogfy-c2a4d", 4 | "private_key_id": "59cb69632aa4b0066c702f8e6061a9d2bf2f6246", 5 | "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCx2v8EDndtBQbI\neSAmkKcTkP5ivteynmSwCdkxSlzSnb6JautA8Ws6bmRp3u7qPcWe4qgdvW/91RYd\nEvrq/xIN6jLEwIyP5XhbpAmrlb735Byy6irzcakd1A432MSE4AWRvwuysAAH+AxI\nKDU98f3Tp9pSkZ7sh8Yex/j8MPusq0TXyByRuCN4RInlDuSTo7DRvb+LrUfU5yFu\nPKsEhNh1c9/ujv8pckOO3mEBEnJyFMgdSGiyDyWE0zeXaixr5ARXkv/LAe5Qoe0I\nBRt35bFJfLA4TQtc8dKxmG9BL0RbjXEBP9ZrXQJM3C4WI9e8Ah6AqMzod+ZakFZP\nSp4s3S29AgMBAAECggEAGxcKg1VpwuECXrw2VbFwRWZvHN7JL3N5Tif93UhgmZL9\n3+P5d8+d381LPaX/eY3VoUUQStFdMspHXz/SGMOnvhdXSskyT8OpwLmcYHaDWzIz\nXdwVlTWRhxHS5ZY+nqeZqZCyKTIuGwig5Ee9jlUi7p9xPWThUlE+79bROaaDWxIM\n5VKVkrBd66R/DQc4+Ac41AslUcGVNlDZkCtuCdZr4exH2e8ecO04a+7OfDAClRaa\n49Ek4/04Kzp0daFkxzahcpddRW3pY1SH0TVxVkenFuWhVN/x/gU2Spabf8eFsnbs\n+nUKwh/8NJFYl0rr5bjsASt94ntCEa3he60285NBoQKBgQDsoLz4rLcEWJjenbBZ\nhptBLya/aBYf82D3xxXTu5dbBFmvv3LezJOMSWKUCEMXXslVdn7O9RJtopxEHmBn\nQsYkaUS1kpUAY6qCndPoQ0cFg2SU4DIfPXk3XzPWptvUnUTbFAKD/HPmzFH/+tJd\nEXi0ZG7PhRG0QL+PJPV3JuJE3QKBgQDAaoBzsKNE3GD9oeDPRj5DZxE3wPxmEUmK\nMEdL42D95dYmaFavTAKki9e3YOfAJE/ngeBWHS5C6m/xg+CNwdNGMXATNAdt2iUN\n9OIGU5s0rl9yYOagzYw0aVeFZA2UJb9LN8i9BKYF6ePnPpzTxjTeQ0wtb5PA8Qv+\n6SzT9oEOYQKBgQDoPbJ61vkheNtA0r+8flJunZqIDd55KWOojGstzlX07MYhqeNS\nLLM74uKvq5Q9obg0+wHmmb2mgQyiBrZTYsQaBH99PgqjwS4e2EydDzrrfFQBkjFf\nW/RWlkfIiygC+wATjQYTCHmwsiRg+onw8i7nzhK79jy0D0Bze7C3ayB0uQKBgQCI\nVq5/ywhVEaZz8RDfLZGOpugvTkJJfDRUg1LxdcLTBNkRy9qoST6SIziNik+L+O7Z\nRlCUFAckiQMa6WviZhVy5jLYmIQvFWQuGHdTLkiKMogU/o5MIHkY5g+Kx9NLRtfd\nz0Aglrug8xJ2Vwo+kHIDj0HZ6/aQvvvV+pi19DOL4QKBgQDr4nDpML1Lu0+G/itB\nKhWmIxQFkxQPlnpiN5Glh6RhG9TiBbGbZOxevbgz5RUxCcmY/oLDCxgAPxE06ChE\nKn/ze8M1t67eQMEIcIMNCRwE1zc3FA3OFVgYxPy2ewr/Ad3zkhr6m3Mtv2TlH46e\nZQfO/w2vILTOcHHbmA9uu1U0bA==\n-----END PRIVATE KEY-----\n", 6 | "client_email": "firebase-adminsdk-ev4mq@blogfy-c2a4d.iam.gserviceaccount.com", 7 | "client_id": "106725245641580452498", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-ev4mq%40blogfy-c2a4d.iam.gserviceaccount.com", 12 | "universe_domain": "googleapis.com" 13 | } 14 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=11 --------------------------------------------------------------------------------