diff --git a/config.yaml b/config.yaml index 7fd3dfa..1e52a1d 100644 --- a/config.yaml +++ b/config.yaml @@ -15,7 +15,8 @@ db: auth: jwt: secret: secret # Change this to a long random string - access_token_duration: 15m - refresh_token_duration: 48h + access_token_duration: 1h + refresh_token_duration: 168h + session_max_duration: 336h admins: - mharold@udallas.edu diff --git a/handler/auth.go b/handler/auth.go index 070bdb0..085d1e5 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -419,22 +419,45 @@ func (router *HttpRouter) RefreshToken(c echo.Context) error { return c.JSON(http.StatusUnauthorized, json_response.NewError("unauthorized")) } - accessTokenDetails, err := router.app.RefreshToken(refreshTokenString) + tokenDetails, err := router.app.RefreshToken(refreshTokenString) if err != nil { logger.Error("failed to refresh the access token", zap.Error(err)) - return c.JSON(http.StatusInternalServerError, "failed to refresh the access token") + return c.JSON(http.StatusUnauthorized, json_response.NewError("failed to refresh token")) } c.SetCookie(&http.Cookie{ Name: "access_token", - Value: accessTokenDetails.TokenString, + Value: tokenDetails.AccessToken.TokenString, Path: "/", - Expires: accessTokenDetails.ExpiresAt, + Expires: tokenDetails.AccessToken.ExpiresAt, + Secure: true, + HttpOnly: true, + }) + c.SetCookie(&http.Cookie{ + Name: "refresh_token", + Value: tokenDetails.RefreshToken.TokenString, + Path: "/", + Expires: tokenDetails.RefreshToken.ExpiresAt, Secure: true, HttpOnly: true, }) - return c.JSON(http.StatusOK, json_response.NewMessage("token refreshed")) + if sessionIDCookie, cookieErr := c.Cookie("session_id"); cookieErr == nil { + c.SetCookie(&http.Cookie{ + Name: "session_id", + Value: sessionIDCookie.Value, + Path: "/", + Expires: tokenDetails.RefreshToken.ExpiresAt, + Secure: true, + HttpOnly: true, + }) + } + + return c.JSON(http.StatusOK, echo.Map{ + "message": "token refreshed", + "access_expires_at": tokenDetails.AccessToken.ExpiresAt.UnixMilli(), + "refresh_expires_at": tokenDetails.RefreshToken.ExpiresAt.UnixMilli(), + }) } func (router *HttpRouter) GetClassroomsOfUser(c echo.Context) error { diff --git a/repository/auth.go b/repository/auth.go index a1b7251..9723c7d 100644 --- a/repository/auth.go +++ b/repository/auth.go @@ -144,6 +144,14 @@ func (store PostgresStore) DeleteSession(sessionId uuid.UUID) error { return err } +func (store PostgresStore) UpdateSession(sessionId uuid.UUID, tokenHash string, expiresAt time.Time) error { + _, err := store.db.Exec( + "UPDATE sessions SET token_hash = $2, expires_at = $3 WHERE id = $1;", + sessionId, tokenHash, expiresAt, + ) + return err +} + func (store PostgresStore) GetClassroomsOfUser(userEmail string) ([]models.Classroom, error) { user_info, err := store.GetUserInfo(userEmail) if err != nil { diff --git a/repository/datastore.go b/repository/datastore.go index 2ab9b20..d42705f 100644 --- a/repository/datastore.go +++ b/repository/datastore.go @@ -76,6 +76,7 @@ type Datastore interface { CreateSession(session models.Session) (*models.Session, error) DeleteSession(sessionId uuid.UUID) error GetSession(userEmail string, refreshTokenString string) (*models.Session, error) + UpdateSession(sessionId uuid.UUID, tokenHash string, expiresAt time.Time) error GetClassroomsOfUser(userEmail string) ([]models.Classroom, error) ChangeUserInfo(request models.ChangeUserInfoRequest) error } diff --git a/service/auth.go b/service/auth.go index 98b8b96..dc52b7a 100644 --- a/service/auth.go +++ b/service/auth.go @@ -344,7 +344,7 @@ func (app *GraderApp) PasswordReset(details models.NewPasswordDetails, session m return tokenDetails, nil } -func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.AccessToken, error) { +func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.JWTTokens, error) { refreshTokenClaims, err := jwt_token.ParseRefreshTokenString(refreshTokenString, app.authConfig.JWT.Secret) if err != nil { return nil, fmt.Errorf("invalid autorization credentials") @@ -360,17 +360,20 @@ func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.AccessTok return nil, fmt.Errorf("invalid refresh token") } - accessTokenString, accessTokenExpiration, err := jwt_token.CreateAccessTokenString(session.UserEmail, session.UserRole, app.authConfig.JWT.AccessTokenDuration, app.authConfig.JWT.Secret) + if app.authConfig.JWT.SessionMaxDuration > 0 && time.Now().After(session.CreatedAt.Add(app.authConfig.JWT.SessionMaxDuration)) { + return nil, fmt.Errorf("session exceeded max duration") + } + + tokenDetails, err := jwt_token.CreateJWTTokens(session.UserEmail, session.UserRole, app.authConfig.JWT) if err != nil { - return nil, fmt.Errorf("failed to create access token: %v", err) + return nil, fmt.Errorf("failed to create JWT tokens: %v", err) } - accessToken := &models.AccessToken{ - TokenString: accessTokenString, - ExpiresAt: accessTokenExpiration, + if err := app.store.UpdateSession(session.Id, token.HashToken(tokenDetails.RefreshToken.TokenString), tokenDetails.RefreshToken.ExpiresAt); err != nil { + return nil, fmt.Errorf("failed to update session: %v", err) } - return accessToken, nil + return tokenDetails, nil } func (app *GraderApp) GetClassroomsOfUser(jwksToken string) ([]models.Classroom, error) { diff --git a/service/service.go b/service/service.go index acd4100..89bc5dd 100644 --- a/service/service.go +++ b/service/service.go @@ -18,7 +18,7 @@ type App interface { PasswordResetRequest(jwksToken string) error PasswordResetRequestByEmail(userEmail string) error PasswordReset(details models.NewPasswordDetails, session models.Session) (*models.JWTTokens, error) - RefreshToken(tokenString string) (*models.AccessToken, error) + RefreshToken(tokenString string) (*models.JWTTokens, error) IsValidLogin(jwksToken string) bool ValidInvite(inviteId uuid.UUID, token string) bool ValidPasswordReset(requestId uuid.UUID, tokenString string) bool diff --git a/utils/config/config.go b/utils/config/config.go index aa5ceaa..55329f6 100644 --- a/utils/config/config.go +++ b/utils/config/config.go @@ -43,6 +43,7 @@ type ( Secret string AccessTokenDuration time.Duration `mapstructure:"access_token_duration"` RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"` + SessionMaxDuration time.Duration `mapstructure:"session_max_duration"` } ) @@ -59,6 +60,7 @@ func GetConfig() *Config { viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetDefault("app.timezone", "America/Chicago") + viper.SetDefault("auth.jwt.session_max_duration", "336h") if err := viper.ReadInConfig(); err != nil { panic(err) diff --git a/utils/middlewares/authorization.go b/utils/middlewares/authorization.go index 6297472..e2660f2 100644 --- a/utils/middlewares/authorization.go +++ b/utils/middlewares/authorization.go @@ -2,7 +2,6 @@ package middlewares import ( "fmt" - "time" "github.com/labstack/echo/v4" ) @@ -14,10 +13,6 @@ func ParseCookieForToken(c echo.Context, tokenName string) (tokenString string, return "", fmt.Errorf("could not find `%s` cookie: %v", tokenName, err) } - if cookie.Expires.After(time.Now()) { - return "", fmt.Errorf("expired authentication credentials") - } - return cookie.Value, nil } diff --git a/web/README.md b/web/README.md index 74872fd..b32ccb6 100644 --- a/web/README.md +++ b/web/README.md @@ -27,6 +27,22 @@ export default tseslint.config({ - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` - Optionally add `...tseslint.configs.stylisticTypeChecked` + +## Authentication & API requests + +- **Token policy:** Access tokens are short-lived (`1h`), refresh tokens are longer-lived (`7d`), and server-side sessions enforce an absolute max window (`14d`). +- **Client behavior:** The frontend calls `/api/auth/refresh` on protected page load, retries once on `401`, and schedules proactive refreshes before access-token expiry. +- **Refresh rotation:** Each successful refresh rotates the refresh token and slides session expiry forward. +- **Fetch wrapper:** Use the `fetchWithAuth` helper in `web/src/utils/fetcher.ts` for authenticated API calls. It sets `credentials: 'include'` and handles refresh-on-`401`. +- **CORS / cookies:** When running backend and frontend on different origins, ensure the server allows credentials and the frontend origin (for example `Access-Control-Allow-Credentials: true` and `Access-Control-Allow-Origin: `). + +Manual test steps: + +1. Start backend and frontend, then log in. +2. In DevTools, verify `access_token`, `refresh_token`, and `session_id` cookies are present. +3. Keep a protected page open and active. Around access-token expiry, the client should refresh in the background without forcing a reload. +4. Trigger an authenticated API call after access-token expiry and confirm the request succeeds after a transparent refresh+retry. +5. Verify logout still clears all auth cookies and redirects to login. - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: ```js diff --git a/web/package.json b/web/package.json index 00daa45..1be6ceb 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "tsc && vite build --watch", "build": "tsc -b && vite build", "lint": "eslint .", + "test": "vitest run", "preview": "vite preview" }, "dependencies": { @@ -32,6 +33,7 @@ "globals": "^15.9.0", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", - "vite": "^5.4.1" + "vite": "^5.4.1", + "vitest": "^1.2.0" } } diff --git a/web/src/FAQ/FAQ.tsx b/web/src/FAQ/FAQ.tsx index 0acba82..ff15aa0 100644 --- a/web/src/FAQ/FAQ.tsx +++ b/web/src/FAQ/FAQ.tsx @@ -5,6 +5,7 @@ import Logo from "../components/logo/Logo" import NoiseBackground from "../components/background/NoiseBackground" import BlueButton from "../components/buttons/BlueButton" import FaqItem from './FaqItem' +import fetchWithAuth from '../utils/fetcher' import './FAQ.css' /* Answers are JSX rather than strings because several need inline code, lists, @@ -485,7 +486,7 @@ function Faq() { let alive = true; (async () => { try { - const response = await fetch('/api/auth/valid_login'); + const response = await fetchWithAuth('/api/auth/valid_login'); if (response.ok) { const json = await response.json(); if (alive) setLoggedIn(json['message'] === 'true'); diff --git a/web/src/Index.tsx b/web/src/Index.tsx index 1b046ac..f06bceb 100644 --- a/web/src/Index.tsx +++ b/web/src/Index.tsx @@ -1,4 +1,6 @@ import { StrictMode, useEffect, useState } from 'react' +import auth from './utils/auth' +import fetchWithAuth from './utils/fetcher' import { createRoot } from 'react-dom/client' import Navbar from "./components/navbar/Navbar" import Logo from "./components/logo/Logo" @@ -13,7 +15,8 @@ function Home() { let alive = true; (async () => { try { - const response = await fetch('/api/auth/valid_login'); + await auth.init() + const response = await fetchWithAuth('/api/auth/valid_login') if (response.ok) { const json = await response.json(); if (alive) setLoggedIn(json['message'] === 'true'); diff --git a/web/src/about/About.tsx b/web/src/about/About.tsx index 42c4171..108cfdf 100644 --- a/web/src/about/About.tsx +++ b/web/src/about/About.tsx @@ -1,4 +1,5 @@ import { StrictMode, useEffect, useState } from 'react' +import fetchWithAuth from '../utils/fetcher' import { createRoot } from 'react-dom/client' import Navbar from "../components/navbar/Navbar" import Logo from "../components/logo/Logo" @@ -47,7 +48,7 @@ function About() { let alive = true; (async () => { try { - const response = await fetch('/api/auth/valid_login'); + const response = await fetchWithAuth('/api/auth/valid_login') if (response.ok) { const json = await response.json(); if (alive) setLoggedIn(json['message'] === 'true'); diff --git a/web/src/account/AccountSettings.tsx b/web/src/account/AccountSettings.tsx index 4858bd4..06fe05d 100644 --- a/web/src/account/AccountSettings.tsx +++ b/web/src/account/AccountSettings.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import fetchWithAuth from '../utils/fetcher'; import BlueButton from "../components/buttons/BlueButton"; import TextField from "../components/textfield/Textfield"; @@ -30,7 +31,7 @@ function AccountSettings() { const getIsLoggedIn = async () => { if(!loaded) { try { - var response = await fetch('/api/auth/valid_login'); + var response = await fetchWithAuth('/api/auth/valid_login'); if (response.ok) { var json = await response.json(); var loggedIn = json['message'] == 'true'; @@ -48,7 +49,7 @@ function AccountSettings() { const getUserName =async () => { if (!loaded) { try { - var response = await fetch('/api/auth/user_name'); + var response = await fetchWithAuth('/api/auth/user_name'); if (response.ok) { var json = await response.json(); var firstName = json['FirstName']; @@ -79,7 +80,7 @@ function AccountSettings() { setSubmitState(ButtonState.Waiting); setPasswordButtonDisabled(true); try { - var response = await fetch('/api/auth/password', {method: "POST"}); + var response = await fetchWithAuth('/api/auth/password', {method: "POST"}); if (response.ok) { setPasswordMessage("Password reset link sent!") } else { @@ -95,7 +96,7 @@ function AccountSettings() { const onSignOut = async () => { try { - await fetch('/api/auth/logout', { method: "POST" }); + await fetchWithAuth('/api/auth/logout', { method: "POST" }); } finally { window.location.href = "/login"; } @@ -106,7 +107,7 @@ function AccountSettings() { setSubmitState(ButtonState.Error); } else { try { - var response = await fetch('/api/auth/user_info', { + var response = await fetchWithAuth('/api/auth/user_info', { method:"PUT", headers: { 'Content-Type': 'application/json' diff --git a/web/src/account/account.tsx b/web/src/account/account.tsx index 1abe8d3..3a276e7 100644 --- a/web/src/account/account.tsx +++ b/web/src/account/account.tsx @@ -1,10 +1,32 @@ +import { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import auth from "../utils/auth"; import Navbar from "../components/navbar/Navbar"; import './account.css' import AccountSettings from "./AccountSettings"; +function AccountApp() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let alive = true; + (async () => { + await auth.init(); + if (alive) setReady(true); + })(); + return () => { + alive = false; + }; + }, []); + + if (!ready) return null; + + return
+ + +
; +} createRoot(document.getElementById('root')!).render(
- - +
) \ No newline at end of file diff --git a/web/src/assignment/AssignmentBody.tsx b/web/src/assignment/AssignmentBody.tsx index b4e1209..0825e4e 100644 --- a/web/src/assignment/AssignmentBody.tsx +++ b/web/src/assignment/AssignmentBody.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import fetchWithAuth from '../utils/fetcher'; import AssignmentPanel from "../components/assignment/AssignmentPanel"; import Navbar from "../components/navbar/Navbar"; @@ -12,7 +13,7 @@ function AssignmentBody() { useEffect(() => { var isError = false; const getAssignment = async () => { - var response = await fetch(`/api/classroom/assignment/${assignmentId}`); + var response = await fetchWithAuth(`/api/classroom/assignment/${assignmentId}`); if (response.ok) { var json = await response.json(); setAssignmentInfo(json); @@ -22,7 +23,7 @@ function AssignmentBody() { } const verifyLogin = async () => { try { - var response = await fetch('/api/auth/valid_login'); + var response = await fetchWithAuth('/api/auth/valid_login'); if (response.ok) { var json = await response.json(); if (json['message'] != 'true') { diff --git a/web/src/assignment/assignment.tsx b/web/src/assignment/assignment.tsx index 64596bd..66439eb 100644 --- a/web/src/assignment/assignment.tsx +++ b/web/src/assignment/assignment.tsx @@ -1,6 +1,28 @@ +import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import auth from "../utils/auth"; import AssignmentBody from "./AssignmentBody"; +function AssignmentApp() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let alive = true; + (async () => { + await auth.init(); + if (alive) setReady(true); + })(); + + return () => { + alive = false; + }; + }, []); + + return ready ? : null; +} + createRoot(document.getElementById('root')!).render( - + + + ) \ No newline at end of file diff --git a/web/src/classroom/ClassroomBody.tsx b/web/src/classroom/ClassroomBody.tsx index ef6a69b..ae0307c 100644 --- a/web/src/classroom/ClassroomBody.tsx +++ b/web/src/classroom/ClassroomBody.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import fetchWithAuth from '../utils/fetcher'; import AssignmentDropdown from "../components/assignment/AssignmentDropdown"; import QuestionBulletPoint, { CompletionState } from "../components/question/QuestionBulletPoint"; import HomeworkAssignment from "../components/homework/HomeworkAssignment"; @@ -71,7 +72,7 @@ function ClassroomBody() { var isError = false; const verifyLogin = async () => { try { - var response = await fetch('/api/auth/valid_login'); + var response = await fetchWithAuth('/api/auth/valid_login'); if (response.ok) { var json = await response.json(); if (json['message'] != 'true') { @@ -89,7 +90,7 @@ function ClassroomBody() { } }; const getClassroomName = async () => { - var response = await fetch(`/api/classroom/${classroomId}`); + var response = await fetchWithAuth(`/api/classroom/${classroomId}`); if (response.ok) { var json = await response.json(); setClassroomName(json["name"]); @@ -100,7 +101,7 @@ function ClassroomBody() { } } const getAssignments = async () => { - var response = await fetch(`/api/classroom/${classroomId}/view_assignments`); + var response = await fetchWithAuth(`/api/classroom/${classroomId}/view_assignments`); if (response.ok) { var json = await response.json(); setAssignments(json['assignments']); @@ -109,7 +110,7 @@ function ClassroomBody() { } }; const getRole = async () => { - var response = await fetch(`/api/classroom/role/${classroomId}`); + var response = await fetchWithAuth(`/api/classroom/role/${classroomId}`); if (response.ok) { var role = await response.json(); if (role == "instructor" || role == "admin") { diff --git a/web/src/classroom/classroom.tsx b/web/src/classroom/classroom.tsx index 9a35faf..6310c1f 100644 --- a/web/src/classroom/classroom.tsx +++ b/web/src/classroom/classroom.tsx @@ -1,8 +1,26 @@ +import { useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import auth from "../utils/auth"; import './classroom.css' import ClassroomBody from "./ClassroomBody"; +function ClassroomApp() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let alive = true; + (async () => { + await auth.init(); + if (alive) setReady(true); + })(); + return () => { + alive = false; + }; + }, []); + + return ready ? : null; +} createRoot(document.getElementById('root')!).render(
- +
) \ No newline at end of file diff --git a/web/src/components/navbar/Navbar.tsx b/web/src/components/navbar/Navbar.tsx index e516114..1fbc8de 100644 --- a/web/src/components/navbar/Navbar.tsx +++ b/web/src/components/navbar/Navbar.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import fetchWithAuth from '../../utils/fetcher'; import './Navbar.css' import '/global.css' import AnimatedLogo from './AnimatedLogo'; @@ -13,7 +14,7 @@ function Navbar(){ useEffect(() => { const getIsLoggedIn = async () => { try { - var response = await fetch('/api/auth/valid_login'); + var response = await fetchWithAuth('/api/auth/valid_login'); if (response.ok) { var json = await response.json(); setLoggedIn(json['message'] == 'true'); @@ -23,7 +24,7 @@ function Navbar(){ } }; getIsLoggedIn(); - }); + }, []); return (