package auth import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "errors" "fmt" "git.send.nrw/sendnrw/dockwatch/internal/config" "github.com/coreos/go-oidc/v3/oidc" "golang.org/x/oauth2" "net/http" "slices" "strings" "time" ) type contextKey string const userKey contextKey = "user" type User struct { ID int64 `json:"id"` Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` } type Service struct { cfg config.Config db *sql.DB verifier *oidc.IDTokenVerifier oauth oauth2.Config dev User } func New(ctx context.Context, c config.Config, db *sql.DB) (*Service, error) { s := &Service{cfg: c, db: db} if c.Mode == config.ModeAgent { return s, nil } if c.AuthDisabled { now := time.Now().Unix() _, e := db.ExecContext(ctx, `INSERT INTO users(oidc_sub,email,name,role,last_login_at,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(oidc_sub) DO UPDATE SET last_login_at=excluded.last_login_at`, "dev", "dev@local", "Development Admin", "admin", now, now) if e != nil { return nil, e } e = db.QueryRowContext(ctx, `SELECT id,oidc_sub,email,name,role FROM users WHERE oidc_sub='dev'`).Scan(&s.dev.ID, &s.dev.Sub, &s.dev.Email, &s.dev.Name, &s.dev.Role) return s, e } dctx, cancel := context.WithTimeout(ctx, c.HTTPTimeout) defer cancel() p, e := oidc.NewProvider(dctx, c.OIDCIssuer) if e != nil { return nil, fmt.Errorf("oidc discovery: %w", e) } s.verifier = p.Verifier(&oidc.Config{ClientID: c.OIDCClientID}) s.oauth = oauth2.Config{ClientID: c.OIDCClientID, ClientSecret: c.OIDCClientSecret, Endpoint: p.Endpoint(), RedirectURL: c.OIDCRedirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}} return s, nil } func (s *Service) Login(w http.ResponseWriter, r *http.Request) { if s.cfg.AuthDisabled { http.Redirect(w, r, "/", 302) return } state, err := token(24) if err != nil { http.Error(w, "could not initialize login", http.StatusInternalServerError) return } nonce, err := token(24) if err != nil { http.Error(w, "could not initialize login", http.StatusInternalServerError) return } s.temp(w, "dw_state", state) s.temp(w, "dw_nonce", nonce) http.Redirect(w, r, s.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), 302) } func (s *Service) Callback(w http.ResponseWriter, r *http.Request) error { ctx, cancel := context.WithTimeout(r.Context(), s.cfg.HTTPTimeout) defer cancel() sc, e := r.Cookie("dw_state") if e != nil || sc.Value != r.URL.Query().Get("state") { return errors.New("invalid oidc state") } nc, e := r.Cookie("dw_nonce") if e != nil { return errors.New("missing nonce") } tok, e := s.oauth.Exchange(ctx, r.URL.Query().Get("code")) if e != nil { return e } raw, ok := tok.Extra("id_token").(string) if !ok { return errors.New("missing id_token") } id, e := s.verifier.Verify(ctx, raw) if e != nil { return e } var c struct { Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` Preferred string `json:"preferred_username"` Nonce string `json:"nonce"` Groups []string `json:"groups"` } if e = id.Claims(&c); e != nil { return e } if c.Nonce != nc.Value { return errors.New("invalid nonce") } s.clearTemp(w, "dw_state") s.clearTemp(w, "dw_nonce") if c.Name == "" { c.Name = c.Preferred } role := "viewer" if slices.Contains(c.Groups, s.cfg.OIDCOperatorGroup) { role = "operator" } if slices.Contains(c.Groups, s.cfg.OIDCAdminGroup) { role = "admin" } now := time.Now().Unix() _, e = s.db.ExecContext(ctx, `INSERT INTO users(oidc_sub,email,name,role,last_login_at,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(oidc_sub) DO UPDATE SET email=excluded.email,name=excluded.name,role=excluded.role,last_login_at=excluded.last_login_at`, c.Sub, c.Email, c.Name, role, now, now) if e != nil { return e } var uid int64 if e = s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE oidc_sub=?`, c.Sub).Scan(&uid); e != nil { return e } v, e := token(32) if e != nil { return e } h := sha256.Sum256([]byte(v)) exp := time.Now().Add(12 * time.Hour) _, e = s.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, h[:], uid, exp.Unix(), now) if e != nil { return e } http.SetCookie(w, &http.Cookie{Name: "dockwatch_session", Value: v, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, Expires: exp, MaxAge: int(time.Until(exp).Seconds())}) return nil } func (s *Service) Logout(w http.ResponseWriter, r *http.Request) { if c, e := r.Cookie("dockwatch_session"); e == nil { h := sha256.Sum256([]byte(c.Value)) _, _ = s.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, h[:]) } http.SetCookie(w, &http.Cookie{Name: "dockwatch_session", Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: -1}) } func (s *Service) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.cfg.AuthDisabled { next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, s.dev))) return } c, e := r.Cookie("dockwatch_session") if e != nil { http.Error(w, "unauthorized", 401) return } h := sha256.Sum256([]byte(c.Value)) var u User e = s.db.QueryRowContext(r.Context(), `SELECT u.id,u.oidc_sub,u.email,u.name,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, h[:], time.Now().Unix()).Scan(&u.ID, &u.Sub, &u.Email, &u.Name, &u.Role) if e != nil { http.Error(w, "unauthorized", 401) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) }) } func UserFrom(ctx context.Context) (User, bool) { u, ok := ctx.Value(userKey).(User); return u, ok } func RequireRole(min string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u, ok := UserFrom(r.Context()) if !ok || rank(u.Role) < rank(min) { http.Error(w, "forbidden", 403) return } next.ServeHTTP(w, r) }) } func rank(r string) int { switch strings.ToLower(r) { case "admin": return 3 case "operator": return 2 default: return 1 } } func token(n int) (string, error) { b := make([]byte, n) _, e := rand.Read(b) return base64.RawURLEncoding.EncodeToString(b), e } func (s *Service) temp(w http.ResponseWriter, n, v string) { http.SetCookie(w, &http.Cookie{Name: n, Value: v, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: 600}) } func (s *Service) clearTemp(w http.ResponseWriter, n string) { http.SetCookie(w, &http.Cookie{Name: n, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: -1}) }