Files
readeck/internal/auth/users/models.go
Olivier Meunier 822d78d57d TOTP authentication
This is only the first part. When a totp_secret exists for a user, the
authentication then asks for the code and carries on.

The totp lib can handle 6 or 8 letter codes and sha1, sha256 and sha512.
For maximum compatibility with Google Authenticator though, it sticks
to 6 character and sha1.
2025-12-09 07:23:13 +01:00

302 lines
7.3 KiB
Go

// SPDX-FileCopyrightText: © 2020 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Package users contains the models and functions to manage users.
package users
import (
"crypto/rand"
"database/sql/driver"
"encoding/json"
"errors"
"hash"
"io"
"math/big"
"strconv"
"strings"
"time"
"github.com/doug-martin/goqu/v9"
"github.com/hlandau/passlib"
"codeberg.org/readeck/readeck/internal/acls"
"codeberg.org/readeck/readeck/internal/db"
"codeberg.org/readeck/readeck/internal/db/types"
"codeberg.org/readeck/readeck/pkg/base58"
)
func init() {
if err := passlib.UseDefaults(passlib.Defaults20180601); err != nil {
panic(err)
}
}
const (
// TableName is the user table name in database.
TableName = "user"
)
var (
// Users is the user manager.
Users = Manager{}
// ErrNotFound is returned when a user record was not found.
ErrNotFound = errors.New("not found")
)
// User is a user record in database.
type User struct {
locked bool
ID int `db:"id" goqu:"skipinsert,skipupdate"`
UID string `db:"uid"`
Created time.Time `db:"created" goqu:"skipupdate"`
Updated time.Time `db:"updated"`
Username string `db:"username"`
Email string `db:"email"`
Password string `db:"password"`
Group string `db:"group"`
Settings *UserSettings `db:"settings"`
Seed int `db:"seed"`
TOTPSecret []byte `db:"totp_secret"`
}
// Manager is a query helper for user entries.
type Manager struct{}
// Query returns a prepared goqu SelectDataset that can be extended later.
func (m *Manager) Query() *goqu.SelectDataset {
return db.Q().From(goqu.T(TableName).As("u")).Prepared(true)
}
// GetOne executes the a select query and returns the first result or an error
// when there's no result.
func (m *Manager) GetOne(expressions ...goqu.Expression) (*User, error) {
var u User
found, err := m.Query().Where(expressions...).ScanStruct(&u)
switch {
case err != nil:
return nil, err
case !found:
return nil, ErrNotFound
}
return &u, nil
}
// Count returns the number of user in the database.
func (m *Manager) Count() (int64, error) {
return db.Q().From(TableName).Count()
}
// Create insert a new user in the database. The password
// must be present. It will be hashed and updated before insertion.
func (m *Manager) Create(user *User) error {
if strings.TrimSpace(user.Password) == "" {
return errors.New("password is empty")
}
hash, err := passlib.Hash(user.Password)
if err != nil {
return err
}
user.Password = hash
user.Created = time.Now().UTC()
user.Updated = user.Created
user.UID = base58.NewUUID()
user.SetSeed()
ds := db.Q().Insert(TableName).
Rows(user).
Prepared(true)
id, err := db.InsertWithID(ds, "id")
if err != nil {
return err
}
user.ID = id
return nil
}
// Update updates some user values.
func (u *User) Update(v interface{}) error {
if u.ID == 0 {
return errors.New("no ID")
}
_, err := db.Q().Update(TableName).Prepared(true).
Set(v).
Where(goqu.C("id").Eq(u.ID)).
Executor().Exec()
return err
}
// Save updates all the user values.
func (u *User) Save() error {
u.Updated = time.Now().UTC()
return u.Update(u)
}
// Delete removes a user from the database.
func (u *User) Delete() error {
_, err := db.Q().Delete(TableName).Prepared(true).
Where(goqu.C("id").Eq(u.ID)).
Executor().Exec()
return err
}
// UpdateEtag implements [server.Etagger] interface.
func (u *User) UpdateEtag(h hash.Hash) {
io.WriteString(h, u.UID+strconv.FormatInt(u.Updated.UTC().UnixNano(), 10))
}
// GetLastModified implements LastModer interface.
func (u *User) GetLastModified() []time.Time {
return []time.Time{u.Updated}
}
// CheckPassword checks if the given password matches the
// current user password.
func (u *User) CheckPassword(password string) bool {
newhash, err := passlib.Verify(password, u.Password)
if err != nil {
return false
}
// Update the password when needed
if newhash != "" {
_ = u.Update(goqu.Record{"password": newhash, "updated": time.Now().UTC()})
}
return true
}
// HashPassword returns a new hashed password.
func (u *User) HashPassword(password string) (string, error) {
return passlib.Hash(password)
}
// SetPassword set a new user password. It does *not* save the user with its new hashed password.
func (u *User) SetPassword(password string) error {
var err error
if u.Password, err = u.HashPassword(password); err != nil {
return err
}
return nil
}
// SetSeed sets a new seed to the user. It returns the seed as an integer value
// and does *not* save the data but the seed is accessible on the user instance.
func (u *User) SetSeed() int {
s, _ := rand.Int(rand.Reader, big.NewInt(32767))
u.Seed = int(s.Int64())
return u.Seed
}
// HasTOTP returns true if the user has a totp secret.
func (u *User) HasTOTP() bool {
return len(u.TOTPSecret) > 0
}
// RequiresMFA returns true if an MFA method is required upon sign-in.
func (u *User) RequiresMFA() bool {
return u.HasTOTP()
}
// IsAnonymous returns true when the instance is not set to any existing user
// (when ID is 0).
func (u *User) IsAnonymous() bool {
return u.ID == 0
}
// Permissions returns all the user's implicit permissions.
func (u *User) Permissions() []string {
return acls.GetPermissions(u.Group)
}
// HasPermission returns true if the user can perform "act" action
// on "obj" object.
func (u *User) HasPermission(obj, act string) bool {
return acls.Enforce(u.Group, obj, act)
}
// Lock locks the user's username, email and password change.
func (u *User) Lock(v bool) {
u.locked = v
}
// Locked returns the user's locked status.
func (u *User) Locked() bool {
return u.locked
}
// MakePassword generates a password of the given length.
func MakePassword(n int) string {
alphabet := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@$%&<>?"
bytes := make([]byte, n)
rand.Read(bytes)
for i, b := range bytes {
bytes[i] = alphabet[b%byte(len(alphabet))]
}
return string(bytes)
}
// UserSettings contains some user settings.
type UserSettings struct {
DebugInfo bool `json:"debug_info"`
Lang string `json:"lang"`
AddonReminder bool `json:"addon_reminder"`
EmailSettings EmailSettings `json:"email_settings"`
ReaderSettings ReaderSettings `json:"reader_settings"`
}
// EmailSettings contains the user's email settings.
type EmailSettings struct {
ReplyTo string `json:"reply_to"`
EpubTo string `json:"epub_to"`
}
// ReaderSettings contains the reader settings.
type ReaderSettings struct {
Width int `json:"width"`
Font string `json:"font"`
FontSize int `json:"font_size"`
LineHeight int `json:"line_height"`
Justify int `json:"justify"`
Hyphenation int `json:"hyphenation"`
}
// Scan loads a UserSettings instance from a column.
func (s *UserSettings) Scan(value any) error {
if value == nil {
return nil
}
// Default values
s.AddonReminder = true
s.ReaderSettings.Font = "lora"
s.ReaderSettings.FontSize = 3
s.ReaderSettings.LineHeight = 3
v, err := types.JSONBytes(value)
if err != nil {
return err
}
json.Unmarshal(v, s) //nolint:errcheck
return nil
}
// Value encodes a UserSettings value for storage.
func (s *UserSettings) Value() (driver.Value, error) {
v, err := json.Marshal(s)
if err != nil {
return "", err
}
return string(v), nil
}