Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a431056
feat(frontend): remove usos dependency
qamarq Feb 16, 2025
3dbbc58
chore(readme): update main image
qamarq Feb 16, 2025
b0888dd
feat(frontend): topwr umami track
qamarq Feb 16, 2025
ecb0e70
feat(backend): users table
qamarq Feb 16, 2025
847a213
Merge branch 'main' into feat/otp-login-implementation
qamarq Feb 16, 2025
f28276a
Merge branch 'main' into feat/otp-login-implementation
qamarq Feb 16, 2025
c3b8aa3
feat(frontend): completly removed usos dependency with profile
qamarq Feb 16, 2025
7882f51
fix(frontend): knip & login page base
qamarq Feb 16, 2025
fab4235
fix(frontend): knip
qamarq Feb 16, 2025
d7a013b
feat(frontend): login page > otp step
qamarq Feb 16, 2025
a4101ee
feat(backend): email fix and otp routes
qamarq Feb 16, 2025
2b6d5ce
feat: working login with otp or usos
qamarq Feb 16, 2025
2993dc9
feat(frontend): login flow
qamarq Feb 16, 2025
6fde604
fix: lint and format
qamarq Feb 16, 2025
a3450b6
fix: format
qamarq Feb 16, 2025
e8b2596
feat(frontend): onboard
qamarq Feb 16, 2025
192973a
feat(backend): final touch
qamarq Feb 16, 2025
c404baa
chore(frontend): remove comments
qamarq Feb 16, 2025
cec1c4d
fix: onboard
qamarq Feb 16, 2025
1363871
fix: router and http error
qamarq Feb 16, 2025
92c5b9d
fix: typo, error toast, fn names
qamarq Feb 16, 2025
ec2187f
fix(backend): rate limit to otp and small fixes
qamarq Feb 16, 2025
1b1ea69
feat(backend): otp login security
qamarq Feb 16, 2025
9b9850c
feat(backend): otp validators
qamarq Feb 16, 2025
356f634
feat(frontend): usos cleanup
qamarq Feb 17, 2025
beccb3c
fix(frontend): img width, umami inline, img alt
qamarq Feb 17, 2025
8e56fa7
fix(frontend): target blank
qamarq Feb 17, 2025
561f283
fix: is new account
qamarq Feb 17, 2025
21e267e
feat(frontend): auth fn imprvmnt.
qamarq Feb 17, 2025
28eae2e
feat: remove server actions for login
qamarq Feb 17, 2025
08505f2
refactor(frontend): login page
qamarq Feb 17, 2025
ecb3c4c
feat(frontend): remove unnecessary isloading state
qamarq Feb 17, 2025
d22aeaf
fix: typo
qamarq Feb 17, 2025
dd41c0f
fix(frontend): typo, texts, var name, avatar fallback, handle no data
qamarq Feb 17, 2025
66ed45f
fix(backend): remove try-catch
qamarq Feb 17, 2025
20aac72
fix(frontend): better error handling
qamarq Feb 17, 2025
ef1525a
chore(frontend): remove class block stars
qamarq Feb 17, 2025
e08e11d
feat: api group
qamarq Feb 17, 2025
464ceb6
Merge branch 'main' into feat/otp-login-implementation
qamarq Feb 17, 2025
b7b5094
fix: types
qamarq Feb 17, 2025
14d070f
fix: types
qamarq Feb 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Welcome to the repository of the Solvro project, a student organization at the Wrocław University of Science and Technology!

[![Welcome Page](https://i.imgur.com/PSnVCNN.png)](https://planer.solvro.pl)
[![Welcome Page](https://i.imgur.com/dVjBfjS.png)](https://planer.solvro.pl)

## 🎯 Project Goal

Expand Down
1 change: 1 addition & 0 deletions backend/adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default defineConfig({
},
() => import("@adonisjs/mail/mail_provider"),
() => import("@adonisjs/shield/shield_provider"),
() => import("@adonisjs/limiter/limiter_provider"),
],

/*
Expand Down
168 changes: 126 additions & 42 deletions backend/app/controllers/auth_controller.ts
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",
});
}

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({
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" });
}
}
6 changes: 6 additions & 0 deletions backend/app/exceptions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof Error) {
return ctx.response.status(500).send({
message: "Internal server error",
error: error.message,
});
}
return super.handle(error, ctx);
}

Expand Down
13 changes: 13 additions & 0 deletions backend/app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,21 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column()
declare studentNumber: string;
@column()
declare avatar: string | null;
@column()
declare allowNotifications: boolean;

@column()
declare otpCode: string | null;
@column()
declare otpAttempts: number;
@column()
declare otpExpire: DateTime | null;
@column()
declare verified: boolean;
@column()
declare blocked: boolean;

@hasMany(() => Schedule)
declare schedules: HasMany<typeof Schedule>;

Expand Down
14 changes: 14 additions & 0 deletions backend/app/validators/otp.ts
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),
}),
);
5 changes: 4 additions & 1 deletion backend/app/validators/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import vine from "@vinejs/vine";

export const createUserValidator = vine.compile(
vine.object({
allowNotifications: vine.boolean(),
allowNotifications: vine.boolean().optional(),
avatar: vine.string().optional(),
firstName: vine.string().optional(),
lastName: vine.string().optional(),
}),
);
4 changes: 3 additions & 1 deletion backend/commands/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ async function sendEmail(userNotifications: Map<string, string[]>) {
`;
await mail.send((message) => {
message
.from("Planer Solvro <planer@solvro.pl>")
.to(`${studentNumber}@student.pwr.edu.pl`)
.subject("Planer - nastąpiły zmiany w twoich planach")
.subject("Nastąpiły zmiany w twoich planach")
.text("Nastąpiły zmiany w twoich planach")
.html(htmlContent);
});
}
Expand Down
28 changes: 28 additions & 0 deletions backend/config/limiter.ts
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> {}
}
2 changes: 1 addition & 1 deletion backend/config/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const sessionConfig = defineConfig({
* Define how long to keep the session data alive without
* any activity.
*/
age: "2h",
age: "24h",

/**
* Configuration for session cookie and the
Expand Down
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() {
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"]);
});
}
}
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);
}
}
Loading