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
| Role | Access |
|---|---|
student | Lessons, sandbox, exams, labs, dashboard, profile |
instructor | All student access + create/edit own content (v0.3.0) |
admin | Everything + 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
| Claim | Value |
|---|---|
sub | user_id (integer) |
username | username string |
exp | now + 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
| Situation | Code |
|---|---|
No Authorization header | 401 Unauthorized |
| Invalid/expired token | 401 Unauthorized |
| Valid token, inactive user | 403 Forbidden |
| Valid token, wrong role | 403 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_hashfield never returned in API responses
Security Properties
| Property | Status | Notes |
|---|---|---|
JWT in localStorage | ⚠️ Acceptable | XSS risk mitigated by DOMPurify on all v-html |
| No token rotation | Acceptable | 8h expiry, educational platform |
| No MFA | Acceptable | Planned for v0.4.0 |
| CORS origins | ✅ Explicit | No wildcards |
| SQL injection | ✅ Protected | SQLAlchemy parameterized queries |
| Verification scripts | ✅ Protected | base64-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