Authentication
Session-based authentication implementation for the Go backend.
Overview
The SIS uses server-side sessions stored in Valkey/Redis. On login, the server creates a session, stores it in Valkey, and sets an HttpOnly cookie with the session ID. There are no JWT tokens.
Configuration
# .env
# Session store (required)
REDIS_URL=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
# Session settings (configured in code)
# Default TTL: 4 hours
# Sliding window: enabled (extends TTL on each request)
# Max absolute lifetime: 12 hours
# Cookie settings
COOKIE_DOMAIN=localhost
COOKIE_PATH=/api
COOKIE_SECURE=false # true in production (requires HTTPS)Session Service
// internal/shared/auth/session.go
// SessionData is stored as JSON in Valkey for each session
type SessionData struct {
UserID int32 `json:"user_id"`
Email string `json:"email"`
PersonID string `json:"person_id,omitempty"`
IsSuperuser bool `json:"is_superuser"`
CreatedAt int64 `json:"created_at"`
ViaSSO bool `json:"via_sso,omitempty"`
// This session's sliding idle timeout. 0 = SessionConfig.TTL.
IdleTimeoutSec int64 `json:"idle_timeout_sec,omitempty"`
}
// SessionConfig holds session configuration
type SessionConfig struct {
TTL time.Duration // idle-timeout ceiling (default 4h)
SlidingWindow bool // renew TTL on each request
MaxAbsoluteLife time.Duration // absolute max session lifetime (default 12h)
}
// SessionService manages server-side sessions in Valkey
type SessionService struct {
redis *redis.Client
config SessionConfig
}Key Operations
| Operation | Description |
|---|---|
CreateSession(ctx, data) | Generates opaque session ID (32 bytes), stores data in Valkey, returns ID |
GetSession(ctx, sessionID) | Retrieves session data; extends TTL (by the session's own IdleTimeoutSec) if sliding window enabled; enforces max lifetime |
SetIdleTimeout(ctx, sessionID, data, d) | Changes a live session's idle timeout (SET XX KEEPTTL then EXPIRE); never resurrects a logged-out session |
DeleteSession(ctx, sessionID) | Removes session from Valkey (instant revocation) |
DeleteAllUserSessions(ctx, userID) | Scans and removes all sessions for a user (log out everywhere) |
Idle timeout per role
The idle timeout is a property of each session, not one global TTL. auth.IdlePolicy (internal/shared/auth/idle_policy.go) computes it as
min(SESSION_TTL, SESSION_TIMEOUT_MINUTES, roles.session_timeout_minutes of the acting role)Any term that is not set is ignored. SESSION_TIMEOUT_MINUTES is the System Settings value, and roles.session_timeout_minutes was added in migration 266. The policy reads the whole of its input, one setting row plus the roles that set a limit, into memory and refreshes it every 30 s. It deliberately does not use the per-(user, role) permission cache, which a role edit would have to fan out across every holder.
middleware.Auth applies it after the active role is resolved. When the answer differs from the session's stored IdleTimeoutSec, it calls SetIdleTimeout. That happens on the first request after login, after a role switch, and after an administrator edits a limit; ordinary requests write nothing. It is skipped while impersonating, because the session and its idle clock belong to the administrator, and impersonation has its own cap (MaxImpersonationDuration).
GET /auth/me reports the result as session_timeout_minutes. The frontend's inactivity check (stores/auth.ts) uses that value and fetches it again after switchRole.
Cookies
Three cookies are set on login:
// internal/shared/auth/cookies.go
const (
SessionCookieName = "sis_session" // HttpOnly - session ID
CSRFCookieName = "sis_csrf" // NOT HttpOnly - JS reads for CSRF header
LoggedInCookieName = "sis_logged_in" // NOT HttpOnly - JS checks auth state
)| Cookie | HttpOnly | Purpose |
|---|---|---|
sis_session | Yes | Contains the opaque session ID. Sent on all /api requests. |
sis_csrf | No | CSRF token. JavaScript reads this and sends it as X-CSRF-Token header. |
sis_logged_in | No | Flag ("true") so JavaScript can check if a session exists without reading the session cookie. |
All cookies use SameSite=Lax and are configured for the API path.
The cookies are written only at login, so their Max-Age is the absolute cap (12 h), not SESSION_TTL. When they were sized to SESSION_TTL, the browser dropped them 4 h after login even while the server was still sliding an active session. Idle expiry is the server's job: when middleware.Auth finds a session cookie whose session is gone (idle-expired or past the cap), it expires all three cookies on the 401. Otherwise a stale sis_logged_in would send the SPA back to /auth/me on every page load.
Endpoints
Login
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password"
}
Response (200 OK):
Set-Cookie: sis_session=<session_id>; Path=/api; HttpOnly; SameSite=Lax
Set-Cookie: sis_csrf=<csrf_token>; Path=/; SameSite=Lax
Set-Cookie: sis_logged_in=true; Path=/; SameSite=Lax
{
"success": true,
"data": {
"user": {
"auth_user_id": 1,
"email": "user@example.com",
"person_id": "550e8400-e29b-41d4-a716-446655440000",
"is_superuser": false,
"is_staff": true,
"roles": ["Faculty", "Staff"]
},
"csrf_token": "<csrf_token>"
}
}A failed sign-in is 401 with the error envelope; see Sign-in failures are deliberately uniform. A sign-in by a user who holds roles, none of which is usable from the client's address, is 403 network_role_denied; see Role network rules.
Logout
POST /api/v1/auth/logout
Cookie: sis_session=<session_id>
Response (200 OK)
{ "success": true, "data": { "message": "Successfully logged out" } }
Set-Cookie: sis_session=; Max-Age=-1
Set-Cookie: sis_csrf=; Max-Age=-1
Set-Cookie: sis_logged_in=; Max-Age=-1Get Current User
GET /api/v1/auth/me
Cookie: sis_session=<session_id>
Response (200 OK):
{
"success": true,
"data": {
"auth_user_id": 1,
"email": "user@example.com",
"person_id": "550e8400-e29b-41d4-a716-446655440000",
"is_superuser": false,
"is_staff": true,
"roles": ["Faculty", "Staff"],
"via_sso": false,
"session_timeout_minutes": 240,
"client_ip": "10.0.50.17",
"network_restricted_role": "Registrar"
}
}roles is derived from the legacy auth_users flags (User.GetRoles()), which role assignment never maintains, so it is not authorization: the held and acting roles come from GET /authorization/user-roles/my_roles.
via_sso, session_timeout_minutes, client_ip, network_restricted_role and network_role_denied describe the session, not the account, so the handler copies them off the request claims rather than reading them from the database. network_restricted_role is present only when the user's chosen role is not usable from client_ip and the request fell back to another; network_role_denied: true means no held role is usable from there, and every request but this one, logout and stopping an impersonation is being refused.
Change Password
POST /api/v1/auth/change-password
Cookie: sis_session=<session_id>
X-CSRF-Token: <csrf_token>
{
"current_password": "oldpassword",
"new_password": "newpassword"
}Magic Link Authentication
Request a login link sent via email:
POST /api/v1/auth/request-access-link
Content-Type: application/json
{ "email": "user@example.com" }Verify a token from the magic link:
GET /api/v1/auth/verify-token/{token}Login with a valid token:
POST /api/v1/auth/token-login
Content-Type: application/json
{ "token": "abc123..." }Set password after token verification:
POST /api/v1/auth/set-password
Content-Type: application/json
{
"token": "abc123...",
"password": "newpassword"
}User Management (Admin)
GET /api/v1/auth/users - List all users
POST /api/v1/auth/users - Create user
GET /api/v1/auth/users/{userID} - Get user details
PATCH /api/v1/auth/users/{userID} - Update user
DELETE /api/v1/auth/users/{userID} - Delete user
POST /api/v1/auth/users/{userID}/set-password - Admin set password
POST /api/v1/auth/users/{userID}/toggle-active - Toggle user active status
POST /api/v1/auth/create-user-for-person - Create user for existing personAuthentication Middleware
The auth middleware reads the session cookie and validates the session against Valkey:
// internal/middleware/auth.go
// Simplified flow:
func protect(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. Read session ID from cookie
sessionID := auth.GetSessionIDFromCookie(r)
if sessionID == "" {
response.Unauthorized(w, "No session")
return
}
// 2. Look up session in Valkey
sessionData, err := sessionService.GetSession(ctx, sessionID)
if err != nil {
response.Unauthorized(w, "Invalid or expired session")
return
}
// 3. Build user claims from session data
claims := &UserClaims{
UserID: sessionData.UserID,
Email: sessionData.Email,
PersonID: sessionData.PersonID,
IsSuperuser: sessionData.IsSuperuser,
}
// 3a. Put the client address on the context, then resolve the acting
// role through the role network policy. No permitted role at all
// -> 403 network_role_denied on all but a few exempt routes.
// 3b. Move the session's idle timeout to follow the acting role.
// 4. Add claims and the audit actor (person, client IP, user agent)
// to the request context
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
}
}Role network rules
Which roles a session may act under depends on where the request comes from (migration 291). The administrator's view is Role Networks.
Tables
| Table | Holds |
|---|---|
core.networks | A named set of ranges; is_active = false makes it match nothing |
core.network_cidrs | The ranges, as Postgres cidr (a /32 is one host) |
core.role_network_rules | Per (role_id, network_id): effect ALLOW or DENY. The FK to networks is ON DELETE RESTRICT, and the API refuses to delete a network in use |
Evaluation
auth.NetworkPolicy (internal/shared/auth/network_policy.go) decides, for one role and one source address:
a DENY network of the role contains the address -> refused
otherwise an ALLOW network contains it -> permitted
otherwise -> refusedSo a role with no ALLOW is unusable everywhere, and migration 291 refuses to finish if any role has none. An address inside ROLE_NETWORK_BREAKGLASS_CIDRS is permitted every role. An address that does not parse matches nothing (fail closed).
Like IdlePolicy, it keeps the whole rule set in an in-memory snapshot, refreshed every 30 s and immediately after every admin write (Refresh()). A failed reload keeps the last good snapshot; before the first success that is the empty one, which under enforce refuses every restricted role, so the break-glass range is the way back in. It is deliberately not part of the per-(user, role) Valkey permission cache: the address changes per request, the role's grants do not.
Allowed(ctx, userID, roleID) applies ROLE_NETWORK_ENFORCEMENT:
| Mode | Allowed returns |
|---|---|
off | Always true |
log (default; also an empty value) | Always true, but logs role_network_would_deny (user, role, client IP), at most once per 10 minutes per triple |
enforce | The rule result |
An unrecognised mode, or an unparseable break-glass entry, makes cmd/api/main.go exit at startup.
The source address
It is middleware.ClientAddr(r): r.RemoteAddr after the RealIP middleware. That is trustworthy because Caddy overwrites X-Forwarded-For with the real peer; X-Real-IP is not (nginx sets it to Caddy's own container address). ZPA users arrive as the App Connector's address, not their own.
The permission service never sees the *http.Request, so the address rides on the context (auth.WithClientAddr / auth.ClientAddrFrom). middleware.Auth sets it for authenticated routes and middleware.SystemActor for the pre-session AUTH routes. A context with no client address is not filtered, so background jobs and the nightly import are unaffected.
Where it is applied
- Every request.
middleware.Authresolves the acting role throughresolveSessionActing, which walks the user's held roles in priority order. If the storeduser_active_roleis not permitted, that request acts under the highest-priority role that is, andclaims.NetworkRestrictedRolenames the one it could not use.user_active_roleis per user, not per session, so it is never rewritten; the user's choice comes back by itself on an allowed network. No permitted role at all gives403 network_role_deniedon everything except/auth/me,/auth/logout,/user-roles/stop_impersonation,/user-roles/current_impersonation,/impersonation/stopand/impersonation/active(networkDeniedExemptSuffixes). - Login.
authService.createSessionForUsercallscheckLoginNetworkbefore creating the session, for password, magic-link and SSO sign-ins alike. A user who holds roles (inside their effective/expiration window) but none usable from here is refused with403 network_role_denied. A user with no roles yet is let in, because an SSO login derives its roles after the session exists; the per-request check then applies to whatever is derived. The per-request check is the enforcement; this is the courtesy. - Role switching.
SwitchRolerefuses a role the network does not permit, andmy_rolesreportsavailable_from_networkper role plusnetwork_restricted_role. - Impersonation. The target's roles are checked against the administrator's address: impersonating a registrar from off campus gives no registrar access.
- Code that picks roles itself must go through the policy.
PermissionService.RoleAvailableFromNetwork(ctx, userID, roleID)is the hook, and the workflow engine's role queries go throughnetworkScopedAuthRepo(internal/domain/workflowengine/network_roles.go).
The tables are institution policy edited in production: they are not in the permission transfer bundle, and sanitize-db replaces them with a single Anywhere network that every role ALLOWs (see Sanitizing the Database).
Where a request came from: the audit trail
The audited database connector (internal/shared/db/audit_driver.go) pushes the actor into transaction-local session variables that core.audit_row_change() records. Migration 264 added three more beside the actor, and a core.audit_log.client_hostname column:
| Variable | Column | Set on |
|---|---|---|
app.client_ip | ip_address | Every audited web write, and the pre-session AUTH routes |
app.user_agent | user_agent | The same |
app.client_hostname | client_hostname | Only the pre-session AUTH routes (SystemActor with ActorSourceAuth) |
Unset, they read as NULL, so CLI tools and background jobs write rows exactly as before.
A successful sign-in is recorded as the UPDATE of core.auth_users.last_login, so this is what puts an address and a hostname on every sign-in. Failed sign-ins write nothing, and so still do not appear.
Reverse DNS (middleware.LookupHostname) runs only on the AUTH routes, which are rate limited; a lookup on every audited write would cost far more than it tells. It gives up after 300 ms, caches hits and misses for an hour (at most 4,096 entries), and yields "" rather than an error, because a sign-in must never wait on, or fail because of, someone else's DNS server. ZPA users' rows name the App Connector, not their home.
CSRF Protection
State-changing requests (POST, PUT, PATCH, DELETE) require a CSRF token:
- On login, a
sis_csrfcookie is set (readable by JavaScript) - The frontend reads this cookie and sends it as the
X-CSRF-Tokenheader - The CSRF middleware verifies the header matches the cookie
// Frontend: api/index.ts
api.interceptors.request.use((config) => {
if (config.method !== 'get') {
// Read CSRF token from cookie
const csrfToken = getCookie('sis_csrf')
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken
}
}
return config
})Using Authentication in Handlers
Get Current User Claims
func (h *Handler) GetPerson(w http.ResponseWriter, r *http.Request) {
// Get authenticated user from context
claims := middleware.GetClaims(r.Context())
if claims == nil {
response.Unauthorized(w, "Not authenticated")
return
}
// Use claims
userID := claims.UserID
personID := claims.PersonID
isSuperuser := claims.IsSuperuser
// ... handler logic
}Check Impersonation
func (h *Handler) SomeAction(w http.ResponseWriter, r *http.Request) {
claims := middleware.GetClaims(r.Context())
if claims.IsImpersonating {
actualUserID := claims.ActualUserID
slog.Info("impersonation",
"actual_user", actualUserID,
"acting_as", claims.UserID)
}
// ... handler logic
}Password Hashing
Django-compatible PBKDF2-SHA256 for seamless migration from the legacy Django backend:
// internal/shared/auth/password.go
func HashPassword(password string) (string, error) {
salt := generateSalt(22)
iterations := 1200000 // Django 5.x default
hash := pbkdf2.Key(
[]byte(password),
[]byte(salt),
iterations,
32,
sha256.New,
)
// Django password format: algorithm$iterations$salt$hash
return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s",
iterations,
salt,
base64.StdEncoding.EncodeToString(hash),
), nil
}Rate Limiting
Authentication endpoints are rate-limited to 10 requests per minute per IP to prevent brute-force attacks. This applies to:
POST /auth/loginPOST /auth/request-access-linkPOST /auth/token-loginPOST /auth/set-password
Route Protection
Every route registers through authz.Registrar, which forces the choice between "gated on a permission node" and "exempt, for this written reason" to be made at registration time. The auth routes are the clearest illustration of the exemptions, because all three kinds appear together:
// In router.go
const preAuth = "pre-session login flow; rate limited, token/credential checked in handler"
// No session at all. Custom rather than Public because these need the rate limiter
// and the AUTH system actor for the audit trail — they write (last_login, tokens,
// passwords) with nobody logged in.
reg.Custom("POST "+p+"/auth/login", preAuth, authActor, h.Login)
reg.Custom("POST "+p+"/auth/request-access-link", preAuth, authActor, h.RequestAccessLink)
// Authenticated, deliberately not gated on a node: this is how a user finds out who
// they are and how they get out. Gate them and anyone whose active role lacks the
// node is stuck in a session they cannot end.
const ownAccount = "acts only on the caller's own session/account"
reg.SelfService("POST "+p+"/auth/logout", ownAccount, h.Logout)
reg.SelfService("GET "+p+"/auth/me", ownAccount, h.CurrentUser)
reg.SelfService("POST "+p+"/auth/change-password", ownAccount, h.ChangePassword)
// Gated normally.
users := reg.Node("ADMIN.USERS")
users.Handle("GET "+p+"/auth/users", h.ListUsers)SelfService, Public, Custom and Ungated all require a written reason, and coverage_test.go fails the build for a route that has neither a node nor a reason. See Adding Endpoints for the full table.
Sign-in failures are deliberately uniform
Login returns one message — "Invalid email or password" — for an unknown address, a wrong password and a disabled account alike, and verifies a decoy hash when no account matches so that the response time does not distinguish them either. Passwords are PBKDF2-SHA256 at 1.2 million iterations, so skipping that work on the not-found path would be a plainly readable account-existence oracle. Do not "improve" the message for disabled accounts; tell the user out of band instead.
The token endpoints (verify-token, token-login, set-password) likewise share one message across every failure mode: unknown, expired, already used, revoked.
Frontend Integration
The frontend uses cookie-based auth automatically. No tokens are stored in JavaScript:
// api/index.ts - Axios is configured to send cookies
const api = axios.create({
baseURL: '/api/v1',
withCredentials: true, // Send cookies with every request
})
// On 401, redirect to login
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logout()
router.push('/login')
}
return Promise.reject(error)
}
)Development Login
In development (ENVIRONMENT=development), the frontend can auto-login via the VITE_DEV_SKIP_AUTH environment variable, bypassing the login form for faster development iteration.