[server][auth] Password backends

This commit is contained in:
Abhishek Shroff
2025-06-26 09:43:17 +05:30
parent c80ca7a3fb
commit fbe039318a
8 changed files with 123 additions and 83 deletions
+36 -58
View File
@@ -5,17 +5,19 @@ import (
"encoding/base64"
"errors"
"net/http"
"strings"
"time"
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
"codeberg.org/shroff/phylum/server/internal/auth/ldap"
"codeberg.org/shroff/phylum/server/internal/core"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog"
)
var Cfg Config
var cfg Config
var passwordBackend PasswordBackend
const apiTokenLength = 32
const resetTokenLength = 24
@@ -29,19 +31,39 @@ var accessTokenValidity = pgtype.Interval{
var ErrCredentialsInvalid = core.NewError(http.StatusUnauthorized, "credentials_invalid", "invalid credentials")
func SupportsPasswordAuth() bool {
return Cfg.Password.Backend == "crypt"
return passwordBackend != nil
}
func SupportsPasswordReset() bool {
return Cfg.Password.Backend == "crypt"
return passwordBackend != nil && passwordBackend.SupportsPasswordUpdate()
}
func Init(cfg Config, log zerolog.Logger) error {
if cfg.Password.Backend == "crypt" {
passwordBackend = &crypt.Auth{}
} else if cfg.Password.Backend == "ldap" {
if a, err := ldap.NewAuth(cfg.Password.LDAP, log); err != nil {
return err
} else {
passwordBackend = a
}
} else if cfg.Password.Backend != "" {
return errors.New("password backend not recognized: " + cfg.Password.Backend)
}
return nil
}
func VerifyUserPassword(db db.Handler, email, password string) (core.User, error) {
return verifyUserPassword(db, email, password)
if b, err := passwordBackend.VerifyUserPassword(db, email, password); err != nil {
return core.User{}, err
} else if !b {
return core.User{}, ErrCredentialsInvalid
}
return core.UserByEmail(db, email)
}
func CreateAccessToken(db db.TxHandler, email, password string) (core.User, string, error) {
if user, err := verifyUserPassword(db, email, password); err != nil {
if user, err := VerifyUserPassword(db, email, password); err != nil {
return core.User{}, "", err
} else if token, err := insertAccessToken(db, user.ID); err != nil {
return core.User{}, "", err
@@ -67,6 +89,9 @@ func ReadAccessToken(db db.Handler, accessToken string) (user core.User, err err
}
func CreateResetToken(db db.TxHandler, email string) (core.User, string, error) {
if !passwordBackend.SupportsPasswordUpdate() {
return core.User{}, "", errors.New("password update not supported")
}
user, err := core.UserByEmail(db, email)
if err != nil {
return core.User{}, "", err
@@ -81,6 +106,9 @@ func CreateResetToken(db db.TxHandler, email string) (core.User, string, error)
}
func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (core.User, string, error) {
if !passwordBackend.SupportsPasswordUpdate() {
return core.User{}, "", errors.New("password update not supported")
}
user, err := core.UserByEmail(db, email)
if err != nil {
return user, "", err
@@ -89,7 +117,7 @@ func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (cor
// UpdateUserPassword will ensure the password strength
// Not incorrect to do this before token verification because we are in a transaction.
// TODO: Are there perf implications for this in case of malicious actors?
err = updateUserPassword(db, user.ID, password)
err = UpdateUserPassword(db, email, password)
if err != nil {
return core.User{}, "", err
}
@@ -120,26 +148,10 @@ func ResetUserPassword(db db.TxHandler, email, resetToken, password string) (cor
}
func UpdateUserPassword(db db.TxHandler, email, password string) error {
user, err := core.UserByEmail(db, email)
if err != nil {
return err
}
return updateUserPassword(db, user.ID, password)
}
func updateUserPassword(db db.TxHandler, userID int32, password string) error {
if err := checkPasswordStrength(password); err != nil {
return err
}
const q = "UPDATE users SET password_hash = $2::TEXT, modified = NOW() WHERE id = $1::INT"
if hash, err := crypt.Generate(password); err != nil {
return err
} else {
if _, err := db.Exec(q, userID, hash); err != nil {
return err
}
}
return nil
return passwordBackend.UpdateUserPassword(db, email, password)
}
func insertAccessToken(db db.TxHandler, userID int32) (string, error) {
@@ -170,40 +182,6 @@ ON CONFLICT(user_id) DO UPDATE SET token = @token::TEXT, expires = @expires::TIM
return token, nil
}
func verifyUserPassword(db db.Handler, email, password string) (core.User, error) {
if user, passwordHash, err := userPasswordHashByEmail(db, email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return core.User{}, ErrCredentialsInvalid
}
return core.User{}, err
} else if passwordHash == "" {
return core.User{}, ErrCredentialsInvalid
} else {
if b, err := crypt.Verify(password, passwordHash); err != nil {
return core.User{}, err
} else if !b {
return core.User{}, ErrCredentialsInvalid
}
return user, nil
}
}
func userPasswordHashByEmail(db db.Handler, email string) (user core.User, passwordHash string, err error) {
const q = "SELECT id, email, name, home, permissions, password_hash FROM users WHERE email = $1"
row := db.QueryRow(q, strings.ToLower(email))
err = row.Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Home,
&user.Permissions,
&passwordHash)
if errors.Is(err, pgx.ErrNoRows) {
err = ErrCredentialsInvalid
}
return
}
func generateRandomString(n int) string {
b := make([]byte, n*3/4)
rand.Read(b)
+5 -1
View File
@@ -1,6 +1,9 @@
package auth
import "codeberg.org/shroff/phylum/server/internal/auth/crypt"
import (
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
"codeberg.org/shroff/phylum/server/internal/auth/ldap"
)
type Config struct {
Password PasswordConfig `koanf:"password"`
@@ -9,6 +12,7 @@ type Config struct {
type PasswordConfig struct {
Backend string `koanf:"backend"`
Crypt crypt.Config `koanf:"crypt"`
LDAP ldap.Config `koanf:"ldap"`
Requirements PasswordRequirements `koanf:"requirements"`
}
+37 -1
View File
@@ -3,11 +3,47 @@ package crypt
import (
"errors"
"strings"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/jackc/pgx/v5"
)
var Cfg Config
func Verify(password, encodedHash string) (bool, error) {
type Auth struct {
}
func (a *Auth) SupportsPasswordUpdate() bool {
return true
}
func (a *Auth) UpdateUserPassword(db db.Handler, email, password string) error {
const q = "UPDATE users SET password_hash = $2::TEXT, modified = NOW() WHERE email = $1::TEXT"
if hash, err := Generate(password); err != nil {
return err
} else {
if _, err := db.Exec(q, email, hash); err != nil {
return err
}
}
return nil
}
func (a *Auth) VerifyUserPassword(db db.Handler, email, password string) (bool, error) {
const q = "SELECT password_hash FROM users WHERE email = $1::TEXT"
row := db.QueryRow(q, strings.ToLower(email))
var encodedHash string
if err := row.Scan(&encodedHash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return VerifyPasswordHash(password, encodedHash)
}
func VerifyPasswordHash(password, encodedHash string) (bool, error) {
parts := strings.Split(encodedHash, "$")
parts = parts[1:]
+21 -9
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"codeberg.org/shroff/phylum/server/internal/db"
"github.com/go-ldap/ldap/v3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -47,7 +48,7 @@ type Auth struct {
var a Auth
func Init(cfg Config, log zerolog.Logger) error {
func NewAuth(cfg Config, log zerolog.Logger) (*Auth, error) {
a.log = log.With().Str("c", "ldap").Logger()
if cfg.Debug {
a.log = log.Level(zerolog.DebugLevel)
@@ -56,10 +57,10 @@ func Init(cfg Config, log zerolog.Logger) error {
info := a.log.Debug()
if cfg.DNTemplate == "" {
if cfg.Search.BaseDN == "" {
return errors.New("base_dn not set")
return nil, errors.New("base DN not set")
}
if cfg.Search.FilterTemplate == "" {
return errors.New("filter_template not set")
return nil, errors.New("filter template not set")
}
info = info.Str("base_dn", cfg.Search.BaseDN)
info = info.Str("filter_template", cfg.DNTemplate)
@@ -69,7 +70,7 @@ func Init(cfg Config, log zerolog.Logger) error {
info.Msg("Using ldap auth")
if parsedURL, err := url.Parse(cfg.URL); err != nil {
return errors.New("invalid server URL: " + err.Error())
return nil, errors.New("invalid server URL: " + err.Error())
} else {
a.url = cfg.URL
a.startTLS = cfg.StartTLS
@@ -79,7 +80,7 @@ func Init(cfg Config, log zerolog.Logger) error {
if cfg.ConnectTimeout == "" {
a.connectTimeout = 60 * time.Second
} else if d, err := time.ParseDuration(cfg.ConnectTimeout); err != nil {
return errors.New("failed to parse connect timeout: " + err.Error())
return nil, errors.New("failed to parse connect timeout: " + err.Error())
} else {
a.connectTimeout = d
}
@@ -87,7 +88,7 @@ func Init(cfg Config, log zerolog.Logger) error {
if cfg.RequestTimeout == "" {
a.readTimeout = 60 * time.Second
} else if d, err := time.ParseDuration(cfg.RequestTimeout); err != nil {
return errors.New("failed to parse read timeout: " + err.Error())
return nil, errors.New("failed to parse read timeout: " + err.Error())
} else {
a.readTimeout = d
}
@@ -97,9 +98,9 @@ func Init(cfg Config, log zerolog.Logger) error {
var err error
a.conn, err = a.newConn()
if err != nil {
return errors.New("failed to connect: " + err.Error())
return nil, errors.New("failed to connect: " + err.Error())
}
return nil
return &a, nil
}
func (a *Auth) newConn() (*ldap.Conn, error) {
@@ -124,7 +125,15 @@ func (a *Auth) newConn() (*ldap.Conn, error) {
return conn, nil
}
func (a *Auth) VerifyNew(email, password string) (bool, error) {
func (a *Auth) SupportsPasswordUpdate() bool {
return false
}
func (a *Auth) UpdateUserPassword(db db.Handler, username, password string) error {
return errors.New("password update not supported")
}
func (a *Auth) VerifyUserPassword(_ db.Handler, email, password string) (bool, error) {
a.connLock.Lock()
defer a.connLock.Unlock()
@@ -162,6 +171,9 @@ func (a *Auth) VerifyNew(email, password string) (bool, error) {
}
if err := a.conn.Bind(userDN, password); err != nil {
if e, ok := err.(*ldap.Error); ok && e.ResultCode == 49 {
err = nil
}
return false, err
}
+11
View File
@@ -0,0 +1,11 @@
package auth
import (
"codeberg.org/shroff/phylum/server/internal/db"
)
type PasswordBackend interface {
SupportsPasswordUpdate() bool
VerifyUserPassword(db db.Handler, email, password string) (bool, error)
UpdateUserPassword(db db.Handler, email, password string) error
}
+10 -10
View File
@@ -40,25 +40,25 @@ func init() {
}
func checkPasswordStrength(password string) error {
if len(password) < Cfg.Password.Requirements.Length {
return &PasswordStrengthError{Reason: "password must be at least " + strconv.Itoa(Cfg.Password.Requirements.Length) + " characters long."}
if len(password) < cfg.Password.Requirements.Length {
return &PasswordStrengthError{Reason: "password must be at least " + strconv.Itoa(cfg.Password.Requirements.Length) + " characters long."}
}
count := map[charType]int{}
for _, c := range password {
count[charTypes[c]]++
}
if count[charTypeLower] < Cfg.Password.Requirements.Lower {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(Cfg.Password.Requirements.Lower) + " lower case."}
if count[charTypeLower] < cfg.Password.Requirements.Lower {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(cfg.Password.Requirements.Lower) + " lower case."}
}
if count[charTypeUpper] < Cfg.Password.Requirements.Upper {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(Cfg.Password.Requirements.Upper) + " upper case."}
if count[charTypeUpper] < cfg.Password.Requirements.Upper {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(cfg.Password.Requirements.Upper) + " upper case."}
}
if count[charTypeNumeric] < Cfg.Password.Requirements.Numeric {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(Cfg.Password.Requirements.Numeric) + " numeric."}
if count[charTypeNumeric] < cfg.Password.Requirements.Numeric {
return &PasswordStrengthError{Reason: "password must have at least " + strconv.Itoa(cfg.Password.Requirements.Numeric) + " numeric."}
}
if count[charTypeSymbol] < Cfg.Password.Requirements.Symbols {
return &PasswordStrengthError{"password must have at least " + strconv.Itoa(Cfg.Password.Requirements.Symbols) + " symbols."}
if count[charTypeSymbol] < cfg.Password.Requirements.Symbols {
return &PasswordStrengthError{"password must have at least " + strconv.Itoa(cfg.Password.Requirements.Symbols) + " symbols."}
}
return nil
}
+2 -3
View File
@@ -9,7 +9,6 @@ import (
"strings"
"codeberg.org/shroff/phylum/server/internal/auth"
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
"codeberg.org/shroff/phylum/server/internal/command/admin"
"codeberg.org/shroff/phylum/server/internal/command/fs"
"codeberg.org/shroff/phylum/server/internal/command/serve"
@@ -127,10 +126,10 @@ func SetupCommand() {
serve.Cfg = cfg.Server
mail.Cfg = cfg.Mail
core.Cfg = cfg.Core
auth.Cfg = cfg.Auth
crypt.Cfg = cfg.Auth.Password.Crypt
steve.Cfg = cfg.Steve
auth.Init(cfg.Auth, log.Logger)
if !isCmd(cmd, "schema") {
// This will initialize the db, which we don't want yet.
initializeSteve()
+1 -1
View File
@@ -43,7 +43,7 @@ func OpenFileSystemFromPublink(ctx context.Context, id string, password string)
if password == "" {
return nil, ErrInsufficientPermissions
}
if ok, err := crypt.Verify(password, link.PasswordHash); err != nil {
if ok, err := crypt.VerifyPasswordHash(password, link.PasswordHash); err != nil {
return nil, err
} else if !ok {
return nil, ErrInsufficientPermissions