Files
jbergner 45ca18b74e
release-tag / release-image (push) Failing after 1m20s
init
2026-08-31 17:09:21 +02:00

213 lines
6.7 KiB
Go

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})
}