├── .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 |
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 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
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 | [](https://github.com/Zlagi/blogfy-api/actions/workflows/run-build.yml)
4 | [](http://kotlinlang.org)
5 | [](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
--------------------------------------------------------------------------------