mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-05-02 02:00:20 -05:00
[server][auth] Password backends
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user