package api import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "net/http" "sync" "time" "golang.org/x/crypto/bcrypt" ) const sessionCookieName = "wgm_session" const csrfCookieName = "wgm_csrf" const sessionTTL = 12 * time.Hour type session struct { username string csrf string expiresAt time.Time } // SessionStore is a simple in-memory session store (single-process deployment). type SessionStore struct { mu sync.Mutex sessions map[string]*session } func NewSessionStore() *SessionStore { return &SessionStore{sessions: make(map[string]*session)} } func randomToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) { sessionToken, err = randomToken() if err != nil { return "", "", err } csrfToken, err = randomToken() if err != nil { return "", "", err } s.mu.Lock() s.sessions[sessionToken] = &session{ username: username, csrf: csrfToken, expiresAt: time.Now().Add(sessionTTL), } s.mu.Unlock() return sessionToken, csrfToken, nil } func (s *SessionStore) Get(token string) (*session, bool) { s.mu.Lock() defer s.mu.Unlock() sess, ok := s.sessions[token] if !ok || time.Now().After(sess.expiresAt) { delete(s.sessions, token) return nil, false } return sess, true } func (s *SessionStore) Delete(token string) { s.mu.Lock() delete(s.sessions, token) s.mu.Unlock() } // HashPassword bcrypt-hashes a plaintext password for storage. func HashPassword(pw string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) return string(b), err } // CheckPassword compares a plaintext password against a stored bcrypt hash. func CheckPassword(hash, pw string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil } var ErrUnauthenticated = errors.New("unauthenticated") // requireAuth resolves the session from the request cookie, or fails. func (a *API) requireAuth(r *http.Request) (*session, error) { c, err := r.Cookie(sessionCookieName) if err != nil { return nil, ErrUnauthenticated } sess, ok := a.sessions.Get(c.Value) if !ok { return nil, ErrUnauthenticated } return sess, nil } // requireCSRF checks the X-CSRF-Token header against the session's csrf token, // mandatory for all state-changing (non-GET) requests. func requireCSRF(sess *session, r *http.Request) bool { if r.Method == http.MethodGet || r.Method == http.MethodHead { return true } token := r.Header.Get("X-CSRF-Token") return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1 } func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: sessionToken, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(sessionTTL.Seconds()), }) http.SetCookie(w, &http.Cookie{ Name: csrfCookieName, Value: csrfToken, Path: "/", HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: int(sessionTTL.Seconds()), }) } func clearSessionCookies(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1}) }