213 lines
6.7 KiB
Go
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})
|
|
}
|