Skip to content

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 ​

bash
# .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 ​

go
// 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 ​

OperationDescription
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:

go
// 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
)
CookieHttpOnlyPurpose
sis_sessionYesContains the opaque session ID. Sent on all /api requests.
sis_csrfNoCSRF token. JavaScript reads this and sends it as X-CSRF-Token header.
sis_logged_inNoFlag ("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=-1

Get 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"
}

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 person

Authentication Middleware ​

The auth middleware reads the session cookie and validates the session against Valkey:

go
// 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 ​

TableHolds
core.networksA named set of ranges; is_active = false makes it match nothing
core.network_cidrsThe ranges, as Postgres cidr (a /32 is one host)
core.role_network_rulesPer (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                                         -> refused

So 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:

ModeAllowed returns
offAlways 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
enforceThe 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.Auth resolves the acting role through resolveSessionActing, which walks the user's held roles in priority order. If the stored user_active_role is not permitted, that request acts under the highest-priority role that is, and claims.NetworkRestrictedRole names the one it could not use. user_active_role is 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 gives 403 network_role_denied on everything except /auth/me, /auth/logout, /user-roles/stop_impersonation, /user-roles/current_impersonation, /impersonation/stop and /impersonation/active (networkDeniedExemptSuffixes).
  • Login. authService.createSessionForUser calls checkLoginNetwork before 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 with 403 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. SwitchRole refuses a role the network does not permit, and my_roles reports available_from_network per role plus network_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 through networkScopedAuthRepo (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:

VariableColumnSet on
app.client_ipip_addressEvery audited web write, and the pre-session AUTH routes
app.user_agentuser_agentThe same
app.client_hostnameclient_hostnameOnly 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:

  1. On login, a sis_csrf cookie is set (readable by JavaScript)
  2. The frontend reads this cookie and sends it as the X-CSRF-Token header
  3. The CSRF middleware verifies the header matches the cookie
typescript
// 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 ​

go
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 ​

go
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:

go
// 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/login
  • POST /auth/request-access-link
  • POST /auth/token-login
  • POST /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:

go
// 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:

typescript
// 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.

Student Information System Documentation