package api import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" "log" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) const accessLifetime = 5 * time.Minute var errAuthentication = errors.New("authentication required") func (a *API) initAuth() error { if err := os.MkdirAll(a.root, 0700); err != nil { return err } path := filepath.Join(a.root, "auth-signing.key") f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err == nil { key := make([]byte, 32) if _, err = rand.Read(key); err == nil { _, err = f.Write(key) } if err == nil { err = f.Sync() } closeErr := f.Close() if err != nil { return err } if closeErr != nil { return closeErr } } else if !os.IsExist(err) { return err } info, err := os.Lstat(path) if err != nil { return err } if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { return errors.New("unsafe signing key permissions") } a.authKey, err = os.ReadFile(path) if err != nil { return err } if len(a.authKey) != 32 { return errors.New("invalid signing key") } _, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL); CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`) return err } type accessClaims struct { Subject string `json:"sub"` Session string `json:"sid"` Issuer string `json:"iss"` Audience string `json:"aud"` Expires int64 `json:"exp"` } const jwtHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" func (a *API) accessJWT(sid, uid string, expires time.Time) string { payload, _ := json.Marshal(accessClaims{uid, sid, "dsh-panel", "dsh-panel", expires.Unix()}) body := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(payload) mac := hmac.New(sha256.New, a.authKey) mac.Write([]byte(body)) return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func (a *API) verifyAccess(r *http.Request, sid, uid string) error { if a.authErr != nil || len(a.authKey) != 32 { return errAuthentication } c, err := r.Cookie("dsh_access") if err != nil { return errAuthentication } p := strings.Split(c.Value, ".") if len(p) != 3 || p[0] != jwtHeader { return errAuthentication } sig, err := base64.RawURLEncoding.DecodeString(p[2]) if err != nil { return errAuthentication } mac := hmac.New(sha256.New, a.authKey) mac.Write([]byte(p[0] + "." + p[1])) if !hmac.Equal(sig, mac.Sum(nil)) { return errAuthentication } data, err := base64.RawURLEncoding.DecodeString(p[1]) if err != nil { return errAuthentication } var claims accessClaims if json.Unmarshal(data, &claims) != nil || claims.Subject != uid || claims.Session != sid || claims.Issuer != "dsh-panel" || claims.Audience != "dsh-panel" || claims.Expires <= time.Now().Unix() || claims.Expires > time.Now().Add(accessLifetime).Unix() { return errAuthentication } return nil } func refreshToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) return base64.RawURLEncoding.EncodeToString(b), err } func refreshHash(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) } func (a *API) secureCookies(r *http.Request) bool { secure := r.TLS != nil if u, err := url.Parse(a.origin); err == nil && u.Scheme == "https" { secure = true } return secure } func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) { for _, c := range []*http.Cookie{ {Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true}, {Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())}, {Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true}, {Name: "dsh_csrf", Value: csrf, Path: "/"}, } { c.Secure = a.secureCookies(r) c.SameSite = http.SameSiteLaxMode http.SetCookie(w, c) } w.Header().Set("Cache-Control", "no-store") } func (a *API) refresh(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } if a.authErr != nil { fail(w, 503, "authentication unavailable") return } sid, err := r.Cookie("dsh_session") if err != nil { fail(w, 401, "authentication required") return } u, csrf, err := a.s.SessionUser(sid.Value) if err != nil { fail(w, 401, "authentication required") return } old, err := r.Cookie("dsh_refresh") if err != nil { fail(w, 401, "authentication required") return } token, err := refreshToken() if err != nil { fail(w, 500, "session error") return } result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix()) if err != nil { fail(w, 500, "session error") return } n, err := result.RowsAffected() if err != nil || n != 1 { fail(w, 401, "authentication required") return } a.authCookies(w, r, sid.Value, u.ID, csrf, token) writeJSON(w, map[string]any{"user": u, "csrf_token": csrf}) } func (a *API) accountLocked(username string) (bool, error) { var until int64 err := a.s.DB.QueryRow("SELECT locked_until FROM auth_failures WHERE username=?", username).Scan(&until) if err == sql.ErrNoRows { return false, nil } return until > time.Now().Unix(), err } // RunSessionCleanup removes expired sessions, their refresh tokens and elapsed // login locks every ten minutes; without it the sessions table grows without // bound because SessionUser only filters rows on read. func (a *API) RunSessionCleanup(ctx context.Context) { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for { a.cleanupSessions(time.Now().Unix()) select { case <-ctx.Done(): return case <-ticker.C: } } } func (a *API) cleanupSessions(now int64) { for _, q := range []struct{ name, sql string }{ {"refresh tokens", "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE expires<=?)"}, {"sessions", "DELETE FROM sessions WHERE expires<=?"}, {"login locks", "DELETE FROM auth_failures WHERE locked_until>0 AND locked_until<=?"}, } { if _, err := a.s.DB.Exec(q.sql, now); err != nil { log.Printf("session cleanup (%s): %v", q.name, err) } } } func (a *API) failedLogin(username string) error { _, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0) ON CONFLICT(username) DO UPDATE SET failures=CASE WHEN locked_until>0 AND locked_until<=? THEN 1 ELSE failures+1 END, locked_until=CASE WHEN locked_until>0 AND locked_until<=? THEN 0 WHEN failures+1>=5 THEN ? ELSE 0 END`, username, time.Now().Unix(), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix()) return err }