feat: Authentik OIDC SSO, allow/deny RBAC, admin console & Gitea CI
All checks were successful
build-and-push / build (push) Successful in 15m15s
All checks were successful
build-and-push / build (push) Successful in 15m15s
Authentication & RBAC
- Add confidential OIDC client (Authentik) with /auth/oidc/login +
/auth/oidc/callback: discovery, code exchange, id_token verify (go-oidc),
groups claim → role (Archivum-admin → admin, else user). Sessions carry groups.
- Rework ACL into an allow/deny model (new `effect` column + migration).
db.EffectiveAccess resolves user + all groups over the path and its ancestors:
default deny, explicit deny always beats allow.
- Enforce ACL for ALL non-admin users (not just guest) across list/read/save/
delete/move/create/history/diff/images/upload. Admins bypass.
- Seed built-in Archivum-admin / Archivum-reader groups; login allow-list on
users & groups; public (guest) user access is ACL-configurable.
Admin API & UI
- New GraphQL ops: oidcConfig/updateOidcConfig, group CRUD, membership,
setUserRole/setUserLogin/setGroupLogin, userGroups, loginOptions.
- Rebuilt AdminView: SSO config, user/group management + membership, login
toggles, and an allow/deny access-control matrix per path.
- LoginView: "Sign in with Authentik" + public-user option; OIDC callback route.
Rendering/editor
- Fix bug where inline marks (bold/italic/code/strike/link) were dropped on
TipTap→AsciiDoc save. Add RENDERING_IMPROVEMENTS.md with proposals.
CI / build
- .gitea/workflows/build.yaml: build on the Pi5 runner, push
localhost:5000/archivum:{latest,<sha>}. Add .dockerignore; bump Go image to 1.25.
- Docs: ARCHITECTURE.md, README.md, docs/AUTHENTIK_SETUP.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ type Session struct {
|
||||
Username string
|
||||
Token string
|
||||
Role string
|
||||
Groups []string // group names (from OIDC claim or local membership)
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
@@ -59,9 +60,10 @@ func CheckPassword(hashedPassword, password string) error {
|
||||
// Returns the session token, the user's role, and any error.
|
||||
// password is a SHA-256 hash (for local accounts); ldapPassword is plaintext (for LDAP bind).
|
||||
func (m *Manager) Login(database *db.DB, username, password, ldapPassword string) (token, role string, err error) {
|
||||
// Guest login — no password required.
|
||||
// Guest / public login — no password required.
|
||||
if username == "guest" {
|
||||
tok, err := m.createSession("guest", "guest")
|
||||
groups, _ := database.GetUserGroupNames("guest")
|
||||
tok, err := m.createSession("guest", "guest", groups)
|
||||
return tok, "guest", err
|
||||
}
|
||||
|
||||
@@ -74,7 +76,8 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
tok, err := m.createSession(username, user.Role)
|
||||
groups, _ := database.GetUserGroupNames(username)
|
||||
tok, err := m.createSession(username, user.Role, groups)
|
||||
return tok, user.Role, err
|
||||
}
|
||||
// No local password — must be an LDAP-only account.
|
||||
@@ -92,7 +95,8 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
|
||||
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
tok, err := m.createSession(username, user.Role)
|
||||
groups, _ := database.GetUserGroupNames(username)
|
||||
tok, err := m.createSession(username, user.Role, groups)
|
||||
return tok, user.Role, err
|
||||
}
|
||||
// In DB but no password and not LDAP — refuse.
|
||||
@@ -116,10 +120,60 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
tok, err := m.createSession(username, "user")
|
||||
tok, err := m.createSession(username, "user", nil)
|
||||
return tok, "user", err
|
||||
}
|
||||
|
||||
// LoginOIDC establishes a session from a verified OIDC identity. It maps the
|
||||
// group claim to a role (admin group → admin, otherwise user), mirrors the
|
||||
// user and groups into the local DB (for the admin UI and ACL targeting), and
|
||||
// enforces the login allow-list. Returns an error if the account is not
|
||||
// permitted to sign in.
|
||||
func (m *Manager) LoginOIDC(database *db.DB, u *OIDCUser) (token, role string, err error) {
|
||||
m.mu.RLock()
|
||||
cfg := m.cfg
|
||||
m.mu.RUnlock()
|
||||
|
||||
adminGroup, readerGroup := "Archivum-admin", "Archivum-reader"
|
||||
if cfg != nil {
|
||||
oc := cfg.OIDC
|
||||
oc.Normalize()
|
||||
adminGroup, readerGroup = oc.AdminGroup, oc.ReaderGroup
|
||||
}
|
||||
_ = readerGroup // reader currently maps to the ACL-gated "user" role
|
||||
|
||||
// Mirror the token's groups locally so ACLs can target them and the admin
|
||||
// UI can list them.
|
||||
for _, g := range u.Groups {
|
||||
_ = database.CreateOrUpdateGroup(g, true)
|
||||
}
|
||||
|
||||
allowed := database.LoginAllowed(u.Username, u.Groups)
|
||||
|
||||
role = "user"
|
||||
for _, g := range u.Groups {
|
||||
if g == adminGroup {
|
||||
role = "admin"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Provision / refresh the user record and its group mirror. allow_login is
|
||||
// seeded from the gate result on first insert and preserved afterwards.
|
||||
_ = database.CreateOrUpdateExternalUser(u.Username, role, allowed)
|
||||
if role == "admin" {
|
||||
_ = database.SetUserRole(u.Username, "admin")
|
||||
}
|
||||
_ = database.SyncUserGroups(u.Username, u.Groups)
|
||||
|
||||
if !allowed {
|
||||
return "", "", errors.New("this account is not permitted to sign in to Archivum")
|
||||
}
|
||||
|
||||
tok, err := m.createSession(u.Username, role, u.Groups)
|
||||
return tok, role, err
|
||||
}
|
||||
|
||||
// UserAuthType returns "guest", "ldap", or "local" for the given username.
|
||||
// A user with a non-empty pass_hash is always "local", regardless of is_ldap,
|
||||
// so that the frontend hashes the password before sending it.
|
||||
@@ -246,7 +300,7 @@ func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []strin
|
||||
return users, groups, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createSession(username, role string) (string, error) {
|
||||
func (m *Manager) createSession(username, role string, groups []string) (string, error) {
|
||||
token, err := generateToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -256,6 +310,7 @@ func (m *Manager) createSession(username, role string) (string, error) {
|
||||
Username: username,
|
||||
Token: token,
|
||||
Role: role,
|
||||
Groups: groups,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
231
backend/internal/auth/oidc.go
Normal file
231
backend/internal/auth/oidc.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/brasse-b/archivum/internal/config"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OIDCUser is the identity extracted from a verified id_token / userinfo.
|
||||
type OIDCUser struct {
|
||||
Username string
|
||||
Email string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
var ErrOIDCNotConfigured = errors.New("OIDC not configured")
|
||||
|
||||
type oidcState struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// OIDCProvider wraps an OpenID Connect provider and OAuth2 client and manages
|
||||
// the short-lived CSRF state values used during the auth-code flow. It can be
|
||||
// reconfigured at runtime (after an admin changes the OIDC settings).
|
||||
type OIDCProvider struct {
|
||||
mu sync.Mutex
|
||||
cfg config.OIDCConfig
|
||||
redirect string
|
||||
oauth *oauth2.Config
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
ready bool
|
||||
states map[string]oidcState
|
||||
}
|
||||
|
||||
func NewOIDCProvider() *OIDCProvider {
|
||||
return &OIDCProvider{states: make(map[string]oidcState)}
|
||||
}
|
||||
|
||||
// Configure (re)initialises the provider from config. It performs OIDC
|
||||
// discovery against the issuer, which requires network access to the IdP.
|
||||
// Returns an error if discovery fails; the provider is then left disabled.
|
||||
func (p *OIDCProvider) Configure(ctx context.Context, cfg config.OIDCConfig, redirectURL string) error {
|
||||
cfg.Normalize()
|
||||
|
||||
p.mu.Lock()
|
||||
p.cfg = cfg
|
||||
p.redirect = redirectURL
|
||||
p.ready = false
|
||||
p.provider = nil
|
||||
p.verifier = nil
|
||||
p.oauth = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
if !cfg.Enabled || cfg.Issuer == "" || cfg.ClientID == "" || redirectURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oauthCfg := &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email", cfg.GroupsClaim},
|
||||
}
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
|
||||
|
||||
p.mu.Lock()
|
||||
p.provider = provider
|
||||
p.oauth = oauthCfg
|
||||
p.verifier = verifier
|
||||
p.ready = true
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled reports whether the provider is configured and ready.
|
||||
func (p *OIDCProvider) Enabled() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.ready
|
||||
}
|
||||
|
||||
// AuthURL creates a fresh state value and returns the authorization URL to
|
||||
// redirect the browser to.
|
||||
func (p *OIDCProvider) AuthURL() (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.ready {
|
||||
return "", ErrOIDCNotConfigured
|
||||
}
|
||||
state := randToken()
|
||||
p.states[state] = oidcState{expiresAt: time.Now().Add(10 * time.Minute)}
|
||||
p.gcStatesLocked()
|
||||
return p.oauth.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// Exchange validates the state, swaps the code for tokens, verifies the
|
||||
// id_token and returns the resulting identity (with groups). If the id_token
|
||||
// carries no groups claim it falls back to the userinfo endpoint.
|
||||
func (p *OIDCProvider) Exchange(ctx context.Context, state, code string) (*OIDCUser, error) {
|
||||
p.mu.Lock()
|
||||
if !p.ready {
|
||||
p.mu.Unlock()
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
st, ok := p.states[state]
|
||||
if ok {
|
||||
delete(p.states, state)
|
||||
}
|
||||
oauthCfg := p.oauth
|
||||
verifier := p.verifier
|
||||
provider := p.provider
|
||||
cfg := p.cfg
|
||||
p.mu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(st.expiresAt) {
|
||||
return nil, errors.New("invalid or expired state")
|
||||
}
|
||||
|
||||
tok, err := oauthCfg.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New("no id_token in token response")
|
||||
}
|
||||
idTok, err := verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var claims map[string]interface{}
|
||||
if err := idTok.Claims(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &OIDCUser{
|
||||
Username: claimString(claims, cfg.UsernameClaim),
|
||||
Email: claimString(claims, "email"),
|
||||
Groups: claimStrings(claims, cfg.GroupsClaim),
|
||||
}
|
||||
|
||||
// Some providers only expose groups via userinfo — fall back to it.
|
||||
if len(user.Groups) == 0 && provider != nil {
|
||||
if ui, err := provider.UserInfo(ctx, oauth2.StaticTokenSource(tok)); err == nil {
|
||||
var uiClaims map[string]interface{}
|
||||
if err := ui.Claims(&uiClaims); err == nil {
|
||||
user.Groups = claimStrings(uiClaims, cfg.GroupsClaim)
|
||||
if user.Username == "" {
|
||||
user.Username = claimString(uiClaims, cfg.UsernameClaim)
|
||||
}
|
||||
if user.Email == "" {
|
||||
user.Email = claimString(uiClaims, "email")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.Username == "" {
|
||||
user.Username = user.Email
|
||||
}
|
||||
if user.Username == "" {
|
||||
user.Username = idTok.Subject
|
||||
}
|
||||
if user.Username == "" {
|
||||
return nil, errors.New("could not determine username from OIDC claims")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (p *OIDCProvider) gcStatesLocked() {
|
||||
now := time.Now()
|
||||
for k, v := range p.states {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(p.states, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func claimString(claims map[string]interface{}, key string) string {
|
||||
if v, ok := claims[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func claimStrings(claims map[string]interface{}, key string) []string {
|
||||
raw, ok := claims[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return v
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{v}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user