Skip to main content

Authentication Design

Status: Implemented (v0.2.0)

EduCenter uses JWT Bearer token authentication with role-based access control (RBAC). The design is intentionally simple — no MFA, no OAuth, no service accounts.


Roles

RoleAccess
studentLessons, sandbox, exams, labs, dashboard, profile
instructorAll student access + create/edit own content (v0.3.0)
adminEverything + user management, system stats

New registrations are always student. Role is set by admin via PUT /api/admin/users/{id}/role.


Authentication Flow

Login

POST /api/auth/login  { username, password }
→ bcrypt.verify(password, user.password_hash)
→ Create JWT: { sub: user_id, username, exp: now + 480min }
→ Return { access_token, token_type: "bearer" }

Registration

POST /api/auth/register  { username, email, password }
→ Check username uniqueness → 409 if duplicate
→ Check email uniqueness → 409 if duplicate
→ bcrypt.hash(password)
→ INSERT user (role='student', is_active=True)
→ Return User object (201)

JWT Token

ClaimValue
subuser_id (integer)
usernameusername string
expnow + ACCESS_TOKEN_EXPIRE_MINUTES (default 480 = 8h)

Algorithm: HS256. Secret: SECRET_KEY from environment.

No refresh tokens — when the token expires the user logs in again.


Backend — FastAPI Dependencies

# backend/app/auth.py

security = HTTPBearer(auto_error=False)

async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
if credentials is None:
raise HTTPException(401, "Not authenticated") # Missing token
token_data = decode_token(credentials.credentials) # Invalid → 401
user = await user_crud.get_by_username(db, token_data.username)
if not user:
raise HTTPException(401, "User not found")
if not user.is_active:
raise HTTPException(403, "User account is inactive")
return user

async def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != 'admin':
raise HTTPException(403, "Admin access required")
return current_user

Response Codes

SituationCode
No Authorization header401 Unauthorized
Invalid/expired token401 Unauthorized
Valid token, inactive user403 Forbidden
Valid token, wrong role403 Forbidden

Frontend — Token Storage & Interceptor

// frontend/src/services/api.js
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})

api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)

Route Guards

// frontend/src/router/index.js
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else {
next()
}
})

Password Security

  • Algorithm: bcrypt (12 rounds via passlib)
  • No plaintext passwords in logs or responses
  • password_hash field never returned in API responses

Security Properties

PropertyStatusNotes
JWT in localStorage⚠️ AcceptableXSS risk mitigated by DOMPurify on all v-html
No token rotationAcceptable8h expiry, educational platform
No MFAAcceptablePlanned for v0.4.0
CORS origins✅ ExplicitNo wildcards
SQL injection✅ ProtectedSQLAlchemy parameterized queries
Verification scripts✅ Protectedbase64-encoded before SSH execution

Planned: Password Reset (v0.3.0)

POST /api/auth/forgot-password  { email }
→ Generate token → store hashed with 1h expiry
→ Send email with reset link
→ Always return 200 (prevents email enumeration)

POST /api/auth/reset-password { token, new_password }
→ Validate token + expiry
→ bcrypt.hash(new_password) → update DB
→ Invalidate token (one-time use)

New env variables: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, FRONTEND_URL