Files
baby-nas/internal/web/auth.go
T
darroyo 03e9368a91 feat: add invalid_users directive for Samba shares
Samba shares now support an 'invalid users' list (deny list), written
as 'invalid users = u1,u2' in smb.conf. The UI shows a ChipPicker
for valid_users and invalid_users, mutually exclusive, sourced from
the system user list.

feat: add ImportSystemUsers for fresh installations

When NASCTL_IMPORT_ON_BOOT=true, nasctl now imports existing system
users from /etc/passwd (UID 1000-60000) and /etc/group (supplemental
groups), and detects which have Samba accounts via 'pdbedit -L'.
Imported users are marked dirty so the admin can review before applying.
New POST /api/import/users endpoint for manual re-import.

This mirrors the existing import-on-boot flow for smb.conf and /etc/exports.
2026-07-06 15:37:01 -04:00

237 lines
5.9 KiB
Go

package web
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/darroyo/nasctl/internal/db"
"github.com/darroyo/nasctl/internal/engine"
)
const (
sessionCookieName = "nasctl_session"
sessionTTL = 12 * time.Hour
signingKeySetting = "session_signing_key"
)
type AuthService struct {
db *db.DB
secret []byte
}
func NewAuthService(database *db.DB) (*AuthService, error) {
secret, err := loadOrCreateSecret(database)
if err != nil {
return nil, err
}
return &AuthService{db: database, secret: secret}, nil
}
func loadOrCreateSecret(database *db.DB) ([]byte, error) {
value, ok, err := database.GetSetting(signingKeySetting)
if err != nil {
return nil, err
}
if ok {
return hex.DecodeString(value)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("generate signing key: %w", err)
}
if err := database.SetSetting(signingKeySetting, hex.EncodeToString(buf)); err != nil {
return nil, err
}
return buf, nil
}
// EnsureAdmin creates an initial admin if none exists.
func (a *AuthService) EnsureAdmin(username, password string) (bool, error) {
count, err := a.db.CountAdmins()
if err != nil {
return false, err
}
if count > 0 {
return false, nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return false, fmt.Errorf("hash password: %w", err)
}
if _, err := a.db.CreateAdmin(username, string(hash)); err != nil {
return false, err
}
return true, nil
}
func (a *AuthService) Authenticate(username, password string) bool {
admin, err := a.db.GetAdminByUsername(username)
if err != nil {
_ = bcrypt.CompareHashAndPassword([]byte("$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinv"), []byte(password))
return false
}
return bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) == nil
}
func (a *AuthService) ChangePassword(username, oldPassword, newPassword string) error {
if len(newPassword) < 8 {
return fmt.Errorf("la nueva contraseña debe tener al menos 8 caracteres")
}
admin, err := a.db.GetAdminByUsername(username)
if err != nil {
return fmt.Errorf("admin no encontrado")
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(oldPassword)); err != nil {
return fmt.Errorf("contraseña actual incorrecta")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if err := a.db.UpdateAdminPasswordHash(admin.ID, string(hash)); err != nil {
return err
}
return nil
}
func (a *AuthService) issueToken(username string) string {
expiry := time.Now().Add(sessionTTL).Unix()
payload := fmt.Sprintf("%s|%d", username, expiry)
sig := a.sign(payload)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (a *AuthService) sign(payload string) string {
mac := hmac.New(sha256.New, a.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *AuthService) verifyToken(token string) (string, bool) {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
expected := a.sign(payload)
if subtle.ConstantTimeCompare([]byte(expected), []byte(parts[1])) != 1 {
return "", false
}
segs := strings.SplitN(payload, "|", 2)
if len(segs) != 2 {
return "", false
}
expiry, err := strconv.ParseInt(segs[1], 10, 64)
if err != nil || time.Now().Unix() > expiry {
return "", false
}
return segs[0], true
}
func (a *AuthService) setSessionCookie(w http.ResponseWriter, username string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: a.issueToken(username),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionTTL),
MaxAge: int(sessionTTL.Seconds()),
})
}
func (a *AuthService) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (a *AuthService) currentUser(r *http.Request) (string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return "", false
}
return a.verifyToken(cookie.Value)
}
// RequireAuth wraps API handlers that must be authenticated.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil {
next.ServeHTTP(w, r)
return
}
if _, ok := s.Auth.currentUser(r); !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
next.ServeHTTP(w, r)
})
}
type Server struct {
DB *db.DB
Engine *engine.Engine
AllowedRoots []string
Auth *AuthService
SMBConfPath string
ExportsPath string
AdminUsername string
UploadMaxBytes int64
PreviewMaxBytes int64
}
type Options struct {
AllowedRoots []string
Auth *AuthService
SMBConfPath string
ExportsPath string
AdminUsername string
UploadMaxBytes int64
PreviewMaxBytes int64
}
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
if opts.UploadMaxBytes == 0 {
opts.UploadMaxBytes = 100 << 20 // 100 MB
}
if opts.PreviewMaxBytes == 0 {
opts.PreviewMaxBytes = 256 << 10 // 256 KB
}
return &Server{
DB: database,
Engine: eng,
AllowedRoots: opts.AllowedRoots,
Auth: opts.Auth,
SMBConfPath: opts.SMBConfPath,
ExportsPath: opts.ExportsPath,
AdminUsername: opts.AdminUsername,
UploadMaxBytes: opts.UploadMaxBytes,
PreviewMaxBytes: opts.PreviewMaxBytes,
}
}
func (s *Server) Handler() http.Handler {
return NewRouter(s)
}