-
Notifications
You must be signed in to change notification settings - Fork 6
feat: OTP login implementation #215
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 37 commits
Commits
Show all changes
41 commits
Select commit
Hold shift + click to select a range
a431056
feat(frontend): remove usos dependency
qamarq 3dbbc58
chore(readme): update main image
qamarq b0888dd
feat(frontend): topwr umami track
qamarq ecb0e70
feat(backend): users table
qamarq 847a213
Merge branch 'main' into feat/otp-login-implementation
qamarq f28276a
Merge branch 'main' into feat/otp-login-implementation
qamarq c3b8aa3
feat(frontend): completly removed usos dependency with profile
qamarq 7882f51
fix(frontend): knip & login page base
qamarq fab4235
fix(frontend): knip
qamarq d7a013b
feat(frontend): login page > otp step
qamarq a4101ee
feat(backend): email fix and otp routes
qamarq 2b6d5ce
feat: working login with otp or usos
qamarq 2993dc9
feat(frontend): login flow
qamarq 6fde604
fix: lint and format
qamarq a3450b6
fix: format
qamarq e8b2596
feat(frontend): onboard
qamarq 192973a
feat(backend): final touch
qamarq c404baa
chore(frontend): remove comments
qamarq cec1c4d
fix: onboard
qamarq 1363871
fix: router and http error
qamarq 92c5b9d
fix: typo, error toast, fn names
qamarq ec2187f
fix(backend): rate limit to otp and small fixes
qamarq 1b1ea69
feat(backend): otp login security
qamarq 9b9850c
feat(backend): otp validators
qamarq 356f634
feat(frontend): usos cleanup
qamarq beccb3c
fix(frontend): img width, umami inline, img alt
qamarq 8e56fa7
fix(frontend): target blank
qamarq 561f283
fix: is new account
qamarq 21e267e
feat(frontend): auth fn imprvmnt.
qamarq 28eae2e
feat: remove server actions for login
qamarq 08505f2
refactor(frontend): login page
qamarq ecb3c4c
feat(frontend): remove unnecessary isloading state
qamarq d22aeaf
fix: typo
qamarq dd41c0f
fix(frontend): typo, texts, var name, avatar fallback, handle no data
qamarq 66ed45f
fix(backend): remove try-catch
qamarq 20aac72
fix(frontend): better error handling
qamarq ef1525a
chore(frontend): remove class block stars
qamarq e08e11d
feat: api group
qamarq 464ceb6
Merge branch 'main' into feat/otp-login-implementation
qamarq b7b5094
fix: types
qamarq 14d070f
fix: types
qamarq File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,65 +1,149 @@ | ||
| import assert from "node:assert"; | ||
| import { DateTime } from "luxon"; | ||
| import crypto from "node:crypto"; | ||
|
|
||
| import { HttpContext } from "@adonisjs/core/http"; | ||
| import mail from "@adonisjs/mail/services/main"; | ||
|
|
||
| import User from "#models/user"; | ||
| import { getOtpValidator, verifyOtpValidator } from "#validators/otp"; | ||
|
|
||
| import { createClient } from "../usos/usos_client.js"; | ||
|
|
||
| export default class AuthController { | ||
| async store({ request, response, auth }: HttpContext) { | ||
| /** | ||
| * Step 1: Get credentials from the request body | ||
| */ | ||
| async loginWithUSOS({ request, response, auth }: HttpContext) { | ||
| const { accessToken, accessSecret } = request.only([ | ||
| "accessToken", | ||
| "accessSecret", | ||
| ]) as { accessToken: string; accessSecret: string }; | ||
| try { | ||
| const usosClient = createClient({ | ||
| token: accessToken, | ||
| secret: accessSecret, | ||
| const usosClient = createClient({ | ||
| token: accessToken, | ||
| secret: accessSecret, | ||
| }); | ||
| const profile = await usosClient.get<{ | ||
| id: string; | ||
| student_number: string; | ||
| first_name: string; | ||
| last_name: string; | ||
| photo_urls: Record<string, string>; | ||
| }>("users/user?fields=id|student_number|first_name|last_name|photo_urls"); | ||
| const user = await User.updateOrCreate( | ||
| { studentNumber: profile.student_number }, | ||
| { | ||
| usosId: profile.id, | ||
| firstName: profile.first_name, | ||
| lastName: profile.last_name, | ||
| avatar: profile.photo_urls["50x50"], | ||
| verified: true, | ||
| }, | ||
| ); | ||
|
|
||
| await auth.use("jwt").generate(user); | ||
|
|
||
| return response.ok({ | ||
| ...user.serialize(), | ||
| }); | ||
| } | ||
|
|
||
| async getOTP({ request, response }: HttpContext) { | ||
| const data = request.all(); | ||
| const { email } = await getOtpValidator.validate(data); | ||
| const studentNumber = email.split("@")[0]; | ||
| let user = await User.findBy("studentNumber", studentNumber); | ||
| if (user === null) { | ||
| user = await User.create({ | ||
| usos_id: "", | ||
| studentNumber, | ||
| firstName: "", | ||
| lastName: "", | ||
| avatar: "", | ||
| verified: false, | ||
| }); | ||
| const profile = await usosClient.get<{ | ||
| id: string; | ||
| student_number: string; | ||
| first_name: string; | ||
| last_name: string; | ||
| }>("users/user?fields=id|student_number|first_name|last_name"); | ||
| let user = await User.findBy("usos_id", profile.id); | ||
| if (user === null) { | ||
| user = await User.create({ | ||
| usos_id: profile.id, | ||
| studentNumber: profile.student_number, | ||
| firstName: profile.first_name, | ||
| lastName: profile.last_name, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| await auth.use("jwt").generate(user); | ||
| const otp = crypto.randomInt(100000, 999999); | ||
| user.otpCode = otp.toString(); | ||
| user.otpAttempts = 0; | ||
| user.otpExpire = DateTime.now().plus({ minutes: 15 }); | ||
| await user.save(); | ||
|
|
||
| return response.ok({ | ||
| ...user.serialize(), | ||
| await mail.send((message) => { | ||
| message | ||
| .from("Solvro Planer <planer@solvro.pl>") | ||
| .to(email) | ||
| .subject("Zweryfikuj adres email") | ||
| .text(`Twój kod weryfikacyjny to: ${otp}`) | ||
| .html(`<h1>Twój kod weryfikacyjny to: ${otp}</h1>`); | ||
| }); | ||
|
|
||
| return response.ok({ | ||
| success: true, | ||
| message: "Wysłano kod weryfikacyjny", | ||
| }); | ||
qamarq marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| async verifyOTP({ request, response, auth }: HttpContext) { | ||
| const data = request.all(); | ||
| const { email, otp } = await verifyOtpValidator.validate(data); | ||
| const user = await User.query() | ||
| .where("studentNumber", email.split("@")[0]) | ||
| .where("otp_expire", ">", new Date()) | ||
| .first(); | ||
| if (user === null) { | ||
| return response.unauthorized({ | ||
| message: "Logowanie nieudane.", | ||
| error: "Invalid OTP", | ||
| }); | ||
| } catch (error) { | ||
| assert(error instanceof Error); | ||
| } | ||
|
|
||
| if (user.blocked) { | ||
| return response.unauthorized({ | ||
qamarq marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| message: "Login failed.", | ||
| error: error.message, | ||
| message: | ||
| "Twoje konto zostało zablokowane na logowanie OTP. Skontaktuj się z administratorem.", | ||
| error: "User is blocked", | ||
| }); | ||
| } | ||
| } | ||
| async destroy({ response }: HttpContext) { | ||
| try { | ||
| response.clearCookie("token"); | ||
|
|
||
| return response.ok({ message: "Successfully logged out" }); | ||
| } catch (error) { | ||
| assert(error instanceof Error); | ||
| return response.internalServerError({ | ||
| message: "Logout failed", | ||
| error: error.message, | ||
|
|
||
| if (user.otpCode !== otp.toString()) { | ||
| user.otpAttempts += 1; | ||
| await user.save(); | ||
| if (user.otpAttempts >= 5) { | ||
| user.otpCode = null; | ||
| user.otpExpire = null; | ||
| user.blocked = true; | ||
| await user.save(); | ||
| return response.unauthorized({ | ||
| message: | ||
| "Logowanie nieudane. Twoje konto zostało zablokowane na logowanie poprzez OTP.", | ||
| error: "Too many attempts", | ||
| }); | ||
| } | ||
| return response.unauthorized({ | ||
| message: "Logowanie nieudane.", | ||
| error: "Invalid OTP", | ||
| }); | ||
| } | ||
|
|
||
| await auth.use("jwt").generate(user); | ||
|
|
||
| let isNewAccount = false; | ||
| if (user.verified === false) { | ||
| isNewAccount = true; | ||
| } | ||
| user.verified = true; | ||
| user.otpCode = null; | ||
| user.otpExpire = null; | ||
| await user.save(); | ||
|
|
||
| return response.ok({ | ||
| success: true, | ||
| user: user.serialize(), | ||
| isNewAccount, | ||
| }); | ||
| } | ||
|
|
||
| async logout({ response }: HttpContext) { | ||
| response.clearCookie("token"); | ||
|
|
||
| return response.ok({ message: "Successfully logged out" }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import vine from "@vinejs/vine"; | ||
|
|
||
| export const getOtpValidator = vine.compile( | ||
| vine.object({ | ||
| email: vine.string().email().endsWith("@student.pwr.edu.pl"), | ||
| }), | ||
| ); | ||
|
|
||
| export const verifyOtpValidator = vine.compile( | ||
| vine.object({ | ||
| email: vine.string().email().endsWith("@student.pwr.edu.pl"), | ||
| otp: vine.string().fixedLength(6), | ||
| }), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { defineConfig, stores } from "@adonisjs/limiter"; | ||
|
|
||
| import env from "#start/env"; | ||
|
|
||
| const limiterConfig = defineConfig({ | ||
| default: env.get("LIMITER_STORE"), | ||
| stores: { | ||
| /** | ||
| * Database store to save rate limiting data inside a | ||
| * MYSQL or PostgreSQL database. | ||
| */ | ||
| database: stores.database({ | ||
| tableName: "rate_limits", | ||
| }), | ||
|
|
||
| /** | ||
| * Memory store could be used during | ||
| * testing | ||
| */ | ||
| memory: stores.memory({}), | ||
| }, | ||
| }); | ||
|
|
||
| export default limiterConfig; | ||
|
|
||
| declare module "@adonisjs/limiter/types" { | ||
| export interface LimitersList extends InferLimiters<typeof limiterConfig> {} | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
29 changes: 29 additions & 0 deletions
29
backend/database/migrations/1739705422939_prepare_users_table_to_otp.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { BaseSchema } from "@adonisjs/lucid/schema"; | ||
|
|
||
| export default class extends BaseSchema { | ||
| protected tableName = "users"; | ||
|
|
||
| async up() { | ||
qamarq marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| this.schema.alterTable(this.tableName, (table) => { | ||
| table.string("avatar").defaultTo(null); | ||
| table.boolean("verified").defaultTo(true); | ||
| table.string("otp_code").defaultTo(null); | ||
| table.integer("otp_attempts").defaultTo(0); | ||
| table.dateTime("otp_expire").defaultTo(null); | ||
| table.boolean("blocked").defaultTo(false); | ||
| table.dropUnique(["usos_id"]); | ||
| }); | ||
| } | ||
|
|
||
| async down() { | ||
| this.schema.alterTable(this.tableName, (table) => { | ||
| table.dropColumn("avatar"); | ||
| table.dropColumn("verified"); | ||
| table.dropColumn("otp_code"); | ||
| table.dropColumn("otp_attempts"); | ||
| table.dropColumn("otp_expire"); | ||
| table.dropColumn("blocked"); | ||
| table.unique(["usos_id"]); | ||
| }); | ||
| } | ||
| } | ||
17 changes: 17 additions & 0 deletions
17
backend/database/migrations/1739748322405_create_rate_limits_table.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { BaseSchema } from "@adonisjs/lucid/schema"; | ||
|
|
||
| export default class extends BaseSchema { | ||
| protected tableName = "rate_limits"; | ||
|
|
||
| async up() { | ||
| this.schema.createTable(this.tableName, (table) => { | ||
| table.string("key", 255).notNullable().primary(); | ||
| table.integer("points", 9).notNullable().defaultTo(0); | ||
| table.bigint("expire").unsigned(); | ||
| }); | ||
| } | ||
|
|
||
| async down() { | ||
| this.schema.dropTable(this.tableName); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.