mirror of
https://codeberg.org/shroff/phylum.git
synced 2026-01-05 19:21:23 -06:00
[server][auth] Create auth package with extensible config for LDAP and OAuth
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -41,11 +42,10 @@ func Require(c *gin.Context) {
|
||||
}
|
||||
|
||||
func extractUserDetails(c *gin.Context) (core.User, error) {
|
||||
userManager := core.UserManagerFromContext(c.Request.Context())
|
||||
if header := c.Request.Header.Get("Authorization"); header == "" {
|
||||
if cookie, err := c.Request.Cookie("auth_token"); err == nil {
|
||||
token := cookie.Value
|
||||
if u, err := userManager.ReadAccessToken(token); err == nil {
|
||||
if u, err := auth.ReadAccessToken(c.Request.Context(), token); err == nil {
|
||||
return u, nil
|
||||
} else {
|
||||
return core.User{}, err
|
||||
@@ -54,16 +54,16 @@ func extractUserDetails(c *gin.Context) (core.User, error) {
|
||||
return core.User{}, err
|
||||
}
|
||||
return core.User{}, errAuthRequired
|
||||
} else if auth, ok := checkAuthHeader(header, "basic"); ok {
|
||||
if email, password, ok := decodeBasicAuth(auth); ok {
|
||||
if u, err := userManager.VerifyUserPassword(email, password); err == nil {
|
||||
} else if authHeader, ok := checkAuthHeader(header, "basic"); ok {
|
||||
if email, password, ok := decodeBasicAuth(authHeader); ok {
|
||||
if u, err := auth.VerifyUserPassword(c.Request.Context(), email, password); err == nil {
|
||||
return u, nil
|
||||
} else {
|
||||
return core.User{}, err
|
||||
}
|
||||
}
|
||||
} else if token, ok := checkAuthHeader(header, "bearer"); ok {
|
||||
if u, err := userManager.ReadAccessToken(token); err == nil {
|
||||
if u, err := auth.ReadAccessToken(c.Request.Context(), token); err == nil {
|
||||
return u, nil
|
||||
} else {
|
||||
return core.User{}, err
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"codeberg.org/shroff/phylum/server/internal/api/authenticator"
|
||||
"codeberg.org/shroff/phylum/server/internal/api/v1/my"
|
||||
"codeberg.org/shroff/phylum/server/internal/api/v1/responses"
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/mail"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -42,10 +44,9 @@ func handlePasswordAuth(c *gin.Context) {
|
||||
panic(core.NewError(http.StatusBadRequest, "missing_params", "Email or password not specified"))
|
||||
}
|
||||
|
||||
userManager := core.UserManagerFromContext(c.Request.Context())
|
||||
if user, err := userManager.VerifyUserPassword(params.Email, params.Password); err != nil {
|
||||
if user, err := auth.VerifyUserPassword(c.Request.Context(), params.Email, params.Password); err != nil {
|
||||
panic(err)
|
||||
} else if token, err := userManager.CreateAccessToken(user); err != nil {
|
||||
} else if token, err := auth.CreateAccessToken(c.Request.Context(), user); err != nil {
|
||||
panic(err)
|
||||
} else if bootstrap, err := my.Bootstrap(c.Request.Context(), user, 0); err != nil {
|
||||
panic(err)
|
||||
@@ -75,7 +76,7 @@ func handleRequestPasswordReset(c *gin.Context) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if token, err := userManager.CreateResetToken(u); err != nil {
|
||||
if token, err := auth.CreateResetToken(db.Get(c.Request.Context()), u); err != nil {
|
||||
panic(err)
|
||||
} else {
|
||||
go func() {
|
||||
@@ -95,9 +96,9 @@ func handleResetPassword(c *gin.Context) {
|
||||
userManager := core.UserManagerFromContext(c.Request.Context())
|
||||
if user, err := userManager.UserByEmail(params.Email); err != nil {
|
||||
panic(err)
|
||||
} else if err := userManager.ResetUserPassword(user, params.Token, params.Password); err != nil {
|
||||
} else if err := auth.ResetUserPassword(db.Get(c.Request.Context()), user, params.Token, params.Password); err != nil {
|
||||
panic(err)
|
||||
} else if token, err := userManager.CreateAccessToken(user); err != nil {
|
||||
} else if token, err := auth.CreateAccessToken(c.Request.Context(), user); err != nil {
|
||||
panic(err)
|
||||
} else if bootstrap, err := my.Bootstrap(c.Request.Context(), user, 0); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
webdav "codeberg.org/shroff/phylum/server/internal/api/webdav/impl"
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -44,7 +45,7 @@ func (h *handler) HandleRequest(c *gin.Context) {
|
||||
if email, pass, ok := c.Request.BasicAuth(); ok {
|
||||
ctx := c.Request.Context()
|
||||
userManager := core.UserManagerFromContext(ctx)
|
||||
if u, err := userManager.VerifyUserPassword(email, pass); err == nil {
|
||||
if u, err := auth.VerifyUserPassword(ctx, email, pass); err == nil {
|
||||
authSuccess = true
|
||||
root := c.Param("root")
|
||||
f = u.OpenFileSystem(ctx)
|
||||
@@ -65,7 +66,7 @@ func (h *handler) HandleRequest(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, core.ErrCredentialsInvalid) {
|
||||
} else if !errors.Is(err, auth.ErrCredentialsInvalid) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
151
server/internal/auth/auth.go
Normal file
151
server/internal/auth/auth.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/rand"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
var Cfg Config
|
||||
|
||||
const accessTokenLength = 16
|
||||
const resetTokenLength = 64
|
||||
const resetTokenDuration = 10 * time.Minute
|
||||
|
||||
var accessTokenValidity = pgtype.Interval{
|
||||
Days: 30,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
var ErrCredentialsInvalid = errors.New("invalid credentials")
|
||||
|
||||
func VerifyUserPassword(ctx context.Context, email, password string) (core.User, error) {
|
||||
if user, passwordHash, err := userPasswordHashByEmail(db.Get(ctx), 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 CreateAccessToken(ctx context.Context, user core.User) (string, error) {
|
||||
const q = `INSERT INTO access_tokens(id, expires, user_id) VALUES ($1::TEXT, NOW() + $2::INTERVAL, $3::INT)`
|
||||
id := rand.GenerateRandomString(accessTokenLength)
|
||||
if _, err := db.Get(ctx).Exec(q, id, accessTokenValidity, user.ID); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ReadAccessToken(ctx context.Context, accessToken string) (user core.User, err error) {
|
||||
const q = `SELECT t.expires, u.id, u.email, u.name, u.permissions, u.home FROM access_tokens t JOIN users u ON t.user_id = u.id WHERE t.id = $1; `
|
||||
row := db.Get(ctx).QueryRow(q, accessToken)
|
||||
|
||||
var expires pgtype.Timestamp
|
||||
err = row.Scan(&expires, &user.ID, &user.Email, &user.Name, &user.Permissions, &user.Home)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = ErrCredentialsInvalid
|
||||
}
|
||||
} else if time.Now().After(expires.Time) {
|
||||
return core.User{}, ErrCredentialsInvalid
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func CreateResetToken(db db.Handler, user core.User) (string, error) {
|
||||
const q = `INSERT INTO reset_tokens(user_id, token, expires)
|
||||
VALUES (@user_id::INT, @token::TEXT, @expires::TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET token = @token::TEXT, expires = @expires::TIMESTAMP`
|
||||
token := rand.GenerateRandomString(resetTokenLength)
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": user.ID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(resetTokenDuration),
|
||||
}
|
||||
if _, err := db.Exec(q, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func ResetUserPassword(d db.Handler, user core.User, token, password string) error {
|
||||
const q = `DELETE FROM reset_tokens WHERE user_id = @user_id::INT AND token = @token::TEXT RETURNING expires`
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": user.ID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(resetTokenDuration),
|
||||
}
|
||||
return d.RunInTx(func(d db.Handler) error {
|
||||
|
||||
// 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(d, user, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := d.QueryRow(q, args)
|
||||
var expires pgtype.Timestamp
|
||||
if err := row.Scan(&expires); err == nil {
|
||||
if time.Now().After(expires.Time) {
|
||||
return ErrCredentialsInvalid
|
||||
}
|
||||
return nil
|
||||
} else if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrCredentialsInvalid
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateUserPassword(db db.Handler, user core.User, 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, user.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return 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
|
||||
}
|
||||
21
server/internal/auth/conifg.go
Normal file
21
server/internal/auth/conifg.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package auth
|
||||
|
||||
import "codeberg.org/shroff/phylum/server/internal/auth/crypt"
|
||||
|
||||
type Config struct {
|
||||
Password PasswordConfig `koanf:"password"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
Backend string `koanf:"backend" `
|
||||
Crypt crypt.Config `koanf:"crypt"`
|
||||
Requirements PasswordRequirements `koanf:"requirements"`
|
||||
}
|
||||
|
||||
type PasswordRequirements struct {
|
||||
Length int `koanf:"length"`
|
||||
Lower int `koanf:"lower"`
|
||||
Upper int `koanf:"upper"`
|
||||
Numeric int `koanf:"numeric"`
|
||||
Symbols int `koanf:"symbols"`
|
||||
}
|
||||
64
server/internal/auth/strength.go
Normal file
64
server/internal/auth/strength.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type charType int
|
||||
|
||||
const (
|
||||
charTypeOther charType = iota
|
||||
charTypeLower
|
||||
charTypeUpper
|
||||
charTypeNumeric
|
||||
charTypeSymbol
|
||||
)
|
||||
|
||||
var charTypes = map[rune]charType{}
|
||||
|
||||
type PasswordStrengthError struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e PasswordStrengthError) Error() string {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
func init() {
|
||||
for _, c := range "abcdefghijklmnopqrstuvwxyz" {
|
||||
charTypes[c] = charTypeLower
|
||||
}
|
||||
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
charTypes[c] = charTypeUpper
|
||||
}
|
||||
for _, c := range "0123456789" {
|
||||
charTypes[c] = charTypeNumeric
|
||||
}
|
||||
for _, c := range "`~!@#$%^&*()-_=+[]{}\\|;:'\",.<>/?" {
|
||||
charTypes[c] = charTypeSymbol
|
||||
}
|
||||
}
|
||||
|
||||
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."}
|
||||
}
|
||||
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[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[charTypeSymbol] < Cfg.Password.Requirements.Symbols {
|
||||
return &PasswordStrengthError{"password must have at least " + strconv.Itoa(Cfg.Password.Requirements.Symbols) + " symbols."}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -53,7 +55,7 @@ func setupPasswdCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
err = core.UserManagerFromContext(context.Background()).UpdateUserPassword(u, password)
|
||||
err = auth.UpdateUserPassword(db.Get(context.Background()), u, password)
|
||||
if err != nil {
|
||||
fmt.Println("could not add user: " + err.Error())
|
||||
os.Exit(1)
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/mail"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -22,7 +24,7 @@ func setupPwresetResetCommand() *cobra.Command {
|
||||
if user, err := manager.UserByEmail(email); err != nil {
|
||||
fmt.Println("unable to find user" + email + ": " + err.Error())
|
||||
os.Exit(1)
|
||||
} else if token, err := manager.CreateResetToken(user); err != nil {
|
||||
} else if token, err := auth.CreateResetToken(db.Get(context.Background()), user); err != nil {
|
||||
fmt.Println("unable to create reset token: " + err.Error())
|
||||
os.Exit(1)
|
||||
} else {
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"path"
|
||||
"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"
|
||||
"codeberg.org/shroff/phylum/server/internal/command/user"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/mail"
|
||||
"codeberg.org/shroff/phylum/server/internal/storage"
|
||||
@@ -117,7 +118,9 @@ func SetupCommand() {
|
||||
serve.Cfg = cfg.Server
|
||||
mail.Cfg = cfg.Mail
|
||||
core.Cfg = cfg.User
|
||||
crypt.Cfg = cfg.Crypt
|
||||
auth.Cfg = cfg.Auth
|
||||
crypt.Cfg = cfg.Auth.Password.Crypt
|
||||
|
||||
if err := storage.Initialize(db.Get(context.Background())); err != nil {
|
||||
logrus.Fatal("Failed to initialize storage: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -13,22 +13,26 @@ storage:
|
||||
|
||||
user:
|
||||
password:
|
||||
length: 12
|
||||
lower: 1
|
||||
upper: 1
|
||||
numeric: 1
|
||||
symbols: 1
|
||||
basedir: /home
|
||||
permission: 0x10 # Invite users
|
||||
|
||||
crypt:
|
||||
hash: argon2
|
||||
argon2:
|
||||
memory: 2048
|
||||
iterations: 6
|
||||
parallelism: 4
|
||||
salt: 32
|
||||
key: 32
|
||||
auth:
|
||||
password:
|
||||
backend: crypt
|
||||
crypt:
|
||||
hash: argon2
|
||||
argon2:
|
||||
memory: 2048
|
||||
iterations: 6
|
||||
parallelism: 4
|
||||
salt: 32
|
||||
key: 32
|
||||
requirements:
|
||||
length: 12
|
||||
lower: 1
|
||||
upper: 1
|
||||
numeric: 1
|
||||
symbols: 1
|
||||
|
||||
server:
|
||||
host:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"codeberg.org/shroff/phylum/server/internal/auth"
|
||||
"codeberg.org/shroff/phylum/server/internal/command/serve"
|
||||
"codeberg.org/shroff/phylum/server/internal/core"
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/mail"
|
||||
"codeberg.org/shroff/phylum/server/internal/storage"
|
||||
@@ -16,5 +16,5 @@ type Config struct {
|
||||
Server serve.Config `koanf:"server"`
|
||||
Mail mail.Config `koanf:"mail"`
|
||||
User core.Config `koanf:"user"`
|
||||
Crypt crypt.Config `koanf:"crypt"`
|
||||
Auth auth.Config `koanf:"auth"`
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
package core
|
||||
|
||||
type Config struct {
|
||||
Password PasswordConfig `koanf:"password"`
|
||||
BaseDir string `koanf:"basedir"`
|
||||
Permisison UserPermissions `koanf:"permission"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
Length int `koanf:"length"`
|
||||
Lower int `koanf:"lower"`
|
||||
Upper int `koanf:"upper"`
|
||||
Numeric int `koanf:"numeric"`
|
||||
Symbols int `koanf:"symbols"`
|
||||
}
|
||||
|
||||
@@ -91,17 +91,9 @@ type UserManager interface {
|
||||
UserEmailByID(id int) (string, error)
|
||||
UserHome(email string) (pgtype.UUID, error)
|
||||
|
||||
// user_auth.go
|
||||
VerifyUserPassword(email, password string) (User, error)
|
||||
CreateAccessToken(user User) (string, error)
|
||||
ReadAccessToken(accessToken string) (User, error)
|
||||
CreateResetToken(user User) (string, error)
|
||||
ResetUserPassword(user User, token, password string) error
|
||||
|
||||
// user_update.go
|
||||
UpdateUserHome(user User, home pgtype.UUID) error
|
||||
UpdateUserName(user User, name string) error
|
||||
UpdateUserPassword(user User, password string) error
|
||||
GrantUserPermissions(user User, permissions UserPermissions) error
|
||||
RevokeUserPermissions(user User, permissions UserPermissions) error
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type charType int
|
||||
|
||||
const (
|
||||
charTypeOther charType = iota
|
||||
charTypeLower
|
||||
charTypeUpper
|
||||
charTypeNumeric
|
||||
charTypeSymbol
|
||||
)
|
||||
|
||||
var charTypes = map[rune]charType{}
|
||||
|
||||
func init() {
|
||||
for _, c := range "abcdefghijklmnopqrstuvwxyz" {
|
||||
charTypes[c] = charTypeLower
|
||||
}
|
||||
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
|
||||
charTypes[c] = charTypeUpper
|
||||
}
|
||||
for _, c := range "0123456789" {
|
||||
charTypes[c] = charTypeNumeric
|
||||
}
|
||||
for _, c := range "`~!@#$%^&*()-_=+[]{}\\|;:'\",.<>/?" {
|
||||
charTypes[c] = charTypeSymbol
|
||||
}
|
||||
}
|
||||
|
||||
func checkPasswordStrength(password string) error {
|
||||
if len(password) < Cfg.Password.Length {
|
||||
return NewError(http.StatusBadRequest, "password_invalid", "Must be at least "+strconv.Itoa(Cfg.Password.Length)+" characters long.")
|
||||
}
|
||||
count := map[charType]int{}
|
||||
for _, c := range password {
|
||||
count[charTypes[c]]++
|
||||
}
|
||||
|
||||
if count[charTypeLower] < Cfg.Password.Lower {
|
||||
return NewError(http.StatusBadRequest, "password_invalid", "Must have at least "+strconv.Itoa(Cfg.Password.Lower)+" lower case.")
|
||||
}
|
||||
if count[charTypeUpper] < Cfg.Password.Upper {
|
||||
return NewError(http.StatusBadRequest, "password_invalid", "Must have at least "+strconv.Itoa(Cfg.Password.Upper)+" upper case.")
|
||||
}
|
||||
if count[charTypeNumeric] < Cfg.Password.Numeric {
|
||||
return NewError(http.StatusBadRequest, "password_invalid", "Must have at least "+strconv.Itoa(Cfg.Password.Numeric)+" numeric.")
|
||||
}
|
||||
if count[charTypeSymbol] < Cfg.Password.Symbols {
|
||||
return NewError(http.StatusBadRequest, "password_invalid", "Must have at least "+strconv.Itoa(Cfg.Password.Symbols)+" symbols.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/auth/crypt"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"codeberg.org/shroff/phylum/server/internal/db"
|
||||
"codeberg.org/shroff/phylum/server/internal/rand"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const accessTokenLength = 16
|
||||
const resetTokenLength = 64
|
||||
const resetTokenDuration = 10 * time.Minute
|
||||
|
||||
var accessTokenValidity = pgtype.Interval{
|
||||
Days: 30,
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
var ErrCredentialsInvalid = NewError(http.StatusUnauthorized, "credentials_invalid", "invalid Credentials")
|
||||
var errTokenNotFound = NewError(http.StatusBadRequest, "token_not_found", "Token Not Found")
|
||||
|
||||
func (m manager) VerifyUserPassword(email, password string) (User, error) {
|
||||
if user, passwordHash, err := m.userPasswordHashByEmail(email); err != nil {
|
||||
if Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrCredentialsInvalid
|
||||
}
|
||||
return User{}, err
|
||||
} else if passwordHash == "" {
|
||||
return User{}, ErrCredentialsInvalid
|
||||
} else {
|
||||
if b, err := crypt.Verify(password, passwordHash); err != nil {
|
||||
return User{}, err
|
||||
} else if !b {
|
||||
return User{}, ErrCredentialsInvalid
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m manager) CreateAccessToken(user User) (string, error) {
|
||||
const q = `INSERT INTO access_tokens(id, expires, user_id) VALUES ($1::TEXT, NOW() + $2::INTERVAL, $3::INT)`
|
||||
id := rand.GenerateRandomString(accessTokenLength)
|
||||
if _, err := m.db.Exec(q, id, accessTokenValidity, user.ID); err != nil {
|
||||
return "", err
|
||||
} else {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m manager) ReadAccessToken(accessToken string) (user User, err error) {
|
||||
const q = `SELECT t.expires, u.id, u.email, u.name, u.permissions, u.home FROM access_tokens t JOIN users u ON t.user_id = u.id WHERE t.id = $1; `
|
||||
row := m.db.QueryRow(q, accessToken)
|
||||
|
||||
var expires pgtype.Timestamp
|
||||
err = row.Scan(&expires, &user.ID, &user.Email, &user.Name, &user.Permissions, &user.Home)
|
||||
if err != nil {
|
||||
if Is(err, pgx.ErrNoRows) {
|
||||
err = ErrCredentialsInvalid
|
||||
}
|
||||
} else if time.Now().After(expires.Time) {
|
||||
return User{}, ErrCredentialsInvalid
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m manager) CreateResetToken(user User) (string, error) {
|
||||
const q = `INSERT INTO reset_tokens(user_id, token, expires)
|
||||
VALUES (@user_id::INT, @token::TEXT, @expires::TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET token = @token::TEXT, expires = @expires::TIMESTAMP`
|
||||
token := rand.GenerateRandomString(resetTokenLength)
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": user.ID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(resetTokenDuration),
|
||||
}
|
||||
if _, err := m.db.Exec(q, args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (m manager) ResetUserPassword(user User, token, password string) error {
|
||||
const q = `DELETE FROM reset_tokens WHERE user_id = @user_id::INT AND token = @token::TEXT RETURNING expires`
|
||||
args := pgx.NamedArgs{
|
||||
"user_id": user.ID,
|
||||
"token": token,
|
||||
"expires": time.Now().Add(resetTokenDuration),
|
||||
}
|
||||
return m.db.RunInTx(func(d db.Handler) error {
|
||||
m := m.withDb(d)
|
||||
|
||||
// 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 := m.UpdateUserPassword(user, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := m.db.QueryRow(q, args)
|
||||
var expires pgtype.Timestamp
|
||||
if err := row.Scan(&expires); err == nil {
|
||||
if time.Now().After(expires.Time) {
|
||||
return errTokenNotFound
|
||||
}
|
||||
return nil
|
||||
} else if Is(err, pgx.ErrNoRows) {
|
||||
return errTokenNotFound
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -137,19 +137,3 @@ RETURNING id, email, name, home, permissions`
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
|
||||
func (m manager) userPasswordHashByEmail(email string) (user User, passwordHash string, err error) {
|
||||
const q = "SELECT id, email, name, home, permissions, password_hash FROM users WHERE email = $1"
|
||||
row := m.db.QueryRow(q, strings.ToLower(email))
|
||||
err = row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Name,
|
||||
&user.Home,
|
||||
&user.Permissions,
|
||||
&passwordHash)
|
||||
if Is(err, pgx.ErrNoRows) {
|
||||
err = ErrUserNotFound
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"codeberg.org/shroff/phylum/server/internal/crypt"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -21,22 +20,6 @@ func (m manager) UpdateUserName(user User, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manager) UpdateUserPassword(user User, 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 := m.db.Exec(q, user.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manager) GrantUserPermissions(user User, permissions UserPermissions) error {
|
||||
const q = "UPDATE users SET permissions = permissions | $2::INTEGER, modified = NOW() WHERE id = $1::INT"
|
||||
if _, err := m.db.Exec(q, user.ID, permissions); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user