People → Sign-in shows single sign-on, Active Directory and SCIM, and the directory's last sync. E sets up Entra ID from the tenant and client IDs; D sets up Active Directory, tried from the control plane first: each server bound, people who may sign in, members of each mapped group. The secrets go into the store as store:auth/..., sealed by the master key, and the CA into the settings, so no control plane needs a file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
179 lines
5.5 KiB
Go
179 lines
5.5 KiB
Go
package router
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/directory"
|
|
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// Sign-in against a directory (auth.ldap), and the accounts kept in step
|
|
// with it by the leader.
|
|
|
|
// ldapDirectory is auth.ldap as the gateway uses it.
|
|
type ldapDirectory struct {
|
|
dir *directory.LDAP
|
|
keeper *directory.Keeper
|
|
every time.Duration
|
|
label string
|
|
log *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
last *adminapi.DirectorySyncView
|
|
}
|
|
|
|
func newLDAPDirectory(cfg config.LDAP, st *storepkg.DB, log *slog.Logger) (*ldapDirectory, error) {
|
|
dir, err := directory.NewLDAP(cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ldapDirectory{
|
|
dir: dir,
|
|
keeper: &directory.Keeper{St: st, Name: "ldap", CreateAccounts: cfg.CreateAccounts,
|
|
Mapping: directory.Mapping{Roles: cfg.Roles, Memberships: cfg.Memberships}},
|
|
every: cfg.Interval(),
|
|
label: cmp.Or(cfg.Label, "Active Directory"),
|
|
log: log,
|
|
}, nil
|
|
}
|
|
|
|
// SignIn is account.Directory: the person's name and password checked by
|
|
// the directory, then their account made or kept in step.
|
|
func (d *ldapDirectory) SignIn(ctx context.Context, login, password string) (storepkg.User, error) {
|
|
id, err := d.dir.Authenticate(ctx, login, password)
|
|
switch {
|
|
case errors.Is(err, directory.ErrBadCredentials):
|
|
return storepkg.User{}, account.ErrBadCredentials
|
|
case err != nil:
|
|
d.log.Warn("the directory could not be asked about a sign-in", "err", err)
|
|
return storepkg.User{}, fmt.Errorf("the directory could not be asked: %w", err)
|
|
}
|
|
u, err := d.keeper.SignIn(ctx, id)
|
|
switch {
|
|
case errors.Is(err, directory.ErrSuspended):
|
|
return storepkg.User{}, account.ErrSuspended
|
|
case errors.Is(err, directory.ErrNoAccount), errors.Is(err, directory.ErrNoEmail):
|
|
// Said to the log, not the form: the form says what a wrong
|
|
// password says.
|
|
d.log.Info("the directory vouched for a person who has no account here", "dn", id.DN, "err", err)
|
|
return storepkg.User{}, account.ErrBadCredentials
|
|
case errors.Is(err, directory.ErrNotActive):
|
|
return storepkg.User{}, account.ErrNotApproved
|
|
case err != nil:
|
|
return storepkg.User{}, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// sync reads the directory every interval, on the leader, and brings the
|
|
// accounts it keeps in step.
|
|
func (d *ldapDirectory) sync(ctx context.Context, leader func() bool) {
|
|
if d.every <= 0 {
|
|
return
|
|
}
|
|
t := time.NewTicker(d.every)
|
|
defer t.Stop()
|
|
for {
|
|
if leader() {
|
|
d.syncOnce(ctx)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *ldapDirectory) syncOnce(ctx context.Context) {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
last := &adminapi.DirectorySyncView{At: time.Now()}
|
|
defer func() {
|
|
d.mu.Lock()
|
|
d.last = last
|
|
d.mu.Unlock()
|
|
}()
|
|
people, err := d.dir.People(ctx, d.keeper.Mapping)
|
|
if err != nil {
|
|
d.log.Warn("could not read the directory; accounts left as they are", "err", err)
|
|
last.Error = "could not read the directory: " + err.Error()
|
|
return
|
|
}
|
|
last.People = len(people)
|
|
rep, err := d.keeper.Sync(ctx, people)
|
|
if err != nil {
|
|
d.log.Error("the directory sync stopped", "err", err, "people", len(people))
|
|
last.Error = err.Error()
|
|
return
|
|
}
|
|
last.Accounts, last.Suspended, last.Reinstated, last.RolesChanged = rep.Seen, rep.Suspended, rep.Reinstated, rep.Updated
|
|
for _, where := range rep.Unmapped {
|
|
d.log.Warn("auth.ldap.memberships maps a group to a place the tree does not have", "place", where)
|
|
}
|
|
if rep.Suspended+rep.Reinstated+rep.Updated > 0 {
|
|
d.log.Info("accounts kept in step with the directory", "people", len(people), "accounts", rep.Seen,
|
|
"suspended", rep.Suspended, "reinstated", rep.Reinstated, "roles_changed", rep.Updated)
|
|
}
|
|
}
|
|
|
|
// lastSync is how the leader's last read of the directory went; nil before
|
|
// the first, and on a standby.
|
|
func (d *ldapDirectory) lastSync() *adminapi.DirectorySyncView {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
return d.last
|
|
}
|
|
|
|
// AuthStatus is how people sign in on this gateway, for the admin API.
|
|
func (r *Router) AuthStatus() adminapi.AuthView {
|
|
v := adminapi.AuthView{SCIM: r.scim.TokenFile != "", BaseURL: r.mail.BaseURL}
|
|
if r.accounts != nil {
|
|
v.SignupOpen = r.accounts.SignupOpen()
|
|
}
|
|
if o := r.oidc; o.Enabled() {
|
|
v.OIDC = &adminapi.OIDCView{Issuer: o.Issuer, ClientID: o.ClientID, Label: o.Label,
|
|
CreateAccounts: o.CreateAccounts, NoPasswords: o.NoPasswords}
|
|
}
|
|
if l := r.ldap; l.Enabled() {
|
|
v.LDAP = &adminapi.LDAPView{URLs: l.URLs, BaseDN: l.BaseDN, BindDN: l.BindDN, Label: l.Label, Off: r.ldapOff}
|
|
if r.directory != nil {
|
|
v.LDAP.LastSync = r.directory.lastSync()
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// TestDirectory tries an auth.ldap block, given as YAML, before it is kept:
|
|
// checked as the settings would be, then each step against the directory.
|
|
func (r *Router) TestDirectory(ctx context.Context, settings, password string) ([]adminapi.CheckView, error) {
|
|
l, err := config.ParseLDAP([]byte(settings))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dir, err := directory.NewLDAP(l)
|
|
if err != nil {
|
|
return []adminapi.CheckView{{Step: "certificate", Detail: err.Error()}}, nil
|
|
}
|
|
if password != "" {
|
|
dir.WithPassword(password)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
checks := dir.Check(ctx, directory.Mapping{Roles: l.Roles, Memberships: l.Memberships})
|
|
out := make([]adminapi.CheckView, len(checks))
|
|
for i, c := range checks {
|
|
out[i] = adminapi.CheckView(c)
|
|
}
|
|
return out, nil
|
|
}
|