Repository navigation
Authentication and RBAC Guide
Rajesh Mani edited this page Sep 13, 2026
·
1 revision
Authentication in StudentAppBackend is built around stateless JSON Web Tokens (JWT) with stateful real-time revocation, combined with strict Role-Based Access Control (RBAC).
-
No Client-Side Privilege Escalation: The client can never specify their own role. Public signup endpoints always assign
role = studenton the server. - Enumeration Resistance: Login and password recovery endpoints return generic responses to prevent attackers from determining whether an email address exists in the system.
- Resource-Level Authorization (Anti-IDOR): Validating a JWT proves identity; accessing a student resource requires ownership or elevated administrative role.
The new canonical signup flow validates input, checks for unique constraint conflicts, normalizes E.164 phone numbers, and hashes passwords:
sequenceDiagram
autonumber
actor Client as iOS / Web Client
participant Auth as AuthController
participant Val as ValidationUtilities
participant Svc as StudentService
participant DB as PostgreSQL 16
Client->>Auth: POST /auth/signup/student {firstName, lastName, email, password, confirmPassword, countryCode, contactNumber}
Auth->>Val: validateStudentSignupRequest(...)
alt Validation Fails
Val-->>Auth: [FieldValidationError]
Auth-->>Client: 400 Bad Request (Detailed Field Errors)
end
Auth->>Svc: signupStudent(request, on: db)
Svc->>Svc: Normalize email (lowercase, trim) & phone (E.164)
Svc->>Svc: Bcrypt.hash(password)
Svc->>DB: INSERT INTO students (id, name, email, passwordHash, role, status, ...)
alt Unique Constraint Collision (Email or Phone)
DB-->>Svc: 23505 Unique Violation
Svc-->>Auth: Abort(.conflict)
Auth-->>Client: 409 Conflict ("EMAIL_ALREADY_EXISTS" / "PHONE_ALREADY_EXISTS")
end
DB-->>Svc: Saved Student Record
Svc-->>Auth: Student Entity
Auth-->>Client: 200 OK (StudentPublicResponse with role: student)
-
email: RFC 5322 compliant regex; converted to lowercase before lookup or insertion. -
password: Minimum 8 characters, must contain at least one digit and one letter. -
countryCode: Must start with+followed by 1 to 4 digits (e.g.+1,+91,+44). -
contactNumber: Normalized into E.164 format (+<countryCode><number>), 7–15 digits.
sequenceDiagram
autonumber
actor Client
participant Auth as AuthController
participant Svc as StudentService
participant Token as TokenService
participant DB as PostgreSQL 16
Client->>Auth: POST /auth/login {email, password}
Auth->>Svc: authenticate(credentials, on: db)
Svc->>DB: SELECT * FROM students WHERE email = normalizedEmail
alt Account Not Found OR Password Invalid OR Suspended
Svc-->>Auth: nil
Auth-->>Client: 401 Unauthorized ("Invalid email or password")
end
Svc->>Svc: Bcrypt.verify(password, against: passwordHash)
Svc-->>Auth: Authenticated Student (with Role)
Auth->>Token: signAccessToken(for: student, on: req)
Token-->>Auth: Signed JWT Token String (HS256)
Auth-->>Client: 200 OK {user: Student.Public, token: {token: "..."}, status: "ok"}
JWTs are stateless by default, which presents a challenge when a user logs out before token expiration. StudentAppBackend solves this using a high-performance Revoked Token Blacklist:
- Client sends
POST /auth/logoutwithAuthorization: Bearer <JWT>. -
JWTAuthMiddlewarevalidates the cryptographic signature. -
TokenService.revokeToken(...)computes the SHA-256 hash of the token and writes it intorevoked_tokenstable with the token's expiration timestamp. - Subsequent requests using this token are immediately rejected with
401 Unauthorized. - An automated cleanup task sweeps expired tokens from
revoked_tokensto prevent unbounded table growth.
sequenceDiagram
autonumber
actor User as User
participant Auth as AuthController
participant Email as SendGridEmailService
participant DB as PostgreSQL 16
participant SendGrid as SendGrid API
Note over User,SendGrid: Phase 1: Request Code
User->>Auth: POST /auth/forgot-password {email}
Auth->>DB: Query student by email
alt Email Not Found
Auth-->>User: 200 OK (Enumeration-Safe: "If the account exists, code was sent")
end
Auth->>DB: Save PasswordResetToken (6-digit code, 10 min TTL, attempts=0)
Auth->>Email: send(to: email, code)
Email->>SendGrid: POST https://api.sendgrid.com/v3/mail/send
Auth-->>User: 200 OK
Note over User,SendGrid: Phase 2: Verify Code
User->>Auth: POST /auth/verify-reset-code {email, code}
Auth->>DB: Lookup active reset token
alt Code Mismatch
Auth->>DB: Increment attempts counter (+1)
alt attempts >= 3
Auth->>DB: Mark token used=true (Brute-Force Lockout)
end
Auth-->>User: 200 OK {success: false, message: "Invalid code."}
end
Auth->>DB: Set verified=true, generate 32-byte sessionToken (15 min TTL)
Auth-->>User: 200 OK {success: true, sessionToken: "xyz..."}
Note over User,SendGrid: Phase 3: Update Password
User->>Auth: POST /auth/reset-password {email, sessionToken, newPassword, confirmPassword}
Auth->>DB: Verify sessionToken matches and verified=true
Auth->>DB: Update student passwordHash = Bcrypt.hash(newPassword)
Auth->>DB: Mark reset token used=true
Auth-->>User: 200 OK {success: true, message: "Password reset successfully"}
Authorization rules are enforced centrally via AuthorizationService:
| Role | Permissions | Resource Access Rules |
|---|---|---|
admin |
Full platform management | Access, read, update all student and teacher records |
principal |
School-level administration | Read student and classroom records within school domain |
teacher |
Classroom instructions | Read students assigned to classes |
student |
Self profile only |
Strict IDOR Barrier: Can only view and update their own record (requester.id == targetID) |
let requester = try await TokenService.authenticateStudent(from: req)
guard let studentIDParam = req.parameters.get("studentID", as: UUID.self) else {
throw Abort(.badRequest, reason: "Invalid student ID format")
}
// Enforce authorization policy BEFORE querying data
guard AuthorizationService.canAccessStudentRecord(requester: requester, targetStudentID: studentIDParam) else {
throw Abort(.forbidden, reason: "You are not authorized to access this student record")
}
guard let student = try await Student.find(studentIDParam, on: req.db) else {
throw Abort(.notFound, reason: "Student not found")
}
return student.convertToPublic()