urit/internal/auth/auth.go
2026-05-02 21:11:50 -04:00

397 lines
11 KiB
Go

// Package auth implements authentication for URIT BBS.
//
// This replaces LOGON.C from the original TAG-BBS. The original used
// numeric account numbers (slot positions in User.Data), plaintext
// passwords compared with StringCompare(), and a simple "3 tries then
// guest" flow. URIT uses usernames, bcrypt password hashing, and
// the same 3-tries-then-guest pattern.
//
// The original's Sysop_Account_Sequence() (auto-login for local console)
// is not needed since we don't have a local serial port. Sysop logs in
// like any other user.
package auth
import (
"fmt"
"strings"
"time"
"unicode"
"golang.org/x/crypto/bcrypt"
"github.com/urit/urit/internal/config"
"github.com/urit/urit/internal/models"
"github.com/urit/urit/internal/session"
"github.com/urit/urit/internal/store"
)
const (
bcryptCost = 12
maxLoginTries = 3
inputTimeout = 60 * time.Second
maxNameLen = 30
maxPassLen = 72 // bcrypt's max input length
minPassLen = 4
)
// Result describes the outcome of the authentication flow.
type Result struct {
User *models.User
IsGuest bool
IsNew bool
}
// Login runs the full login sequence on the given session.
// This is the replacement for Logon_Sequence() in LOGON.C.
//
// The flow:
// 1. Display login screen file if it exists
// 2. Prompt for name or "NEW" or "GUEST"
// 3. If existing user: verify password (3 tries, then forced guest)
// 4. If "NEW": run account creation flow
// 5. If "GUEST": create an ephemeral guest session
// 6. If first-ever user: auto-create as sysop
func Login(sess *session.Session, db store.Store, cfg *config.Config) (*Result, error) {
// Display the login screen file if the sysop has installed one
sess.SendFile(cfg.System.Screens + "login.ans")
// Check if this is a fresh install (no users yet).
// If so, the first person to create an account becomes the sysop.
userCount, err := db.CountUsers()
if err != nil {
return nil, fmt.Errorf("counting users: %w", err)
}
firstRun := userCount == 0
if firstRun {
sess.Color(session.AnsiFgBrightYellow)
sess.WriteString("*** First run detected — first account will be Sysop ***\r\n")
sess.Color(session.AnsiReset)
sess.NewLine()
}
tries := 0
for {
if tries >= maxLoginTries {
sess.WriteString("Three tries and you're out.\r\n")
sess.WriteString("Now you get a Guest account.\r\n\r\n")
return guestLogin(sess, cfg), nil
}
sess.Color(session.AnsiFgCyan)
sess.WriteString("Enter your username (NEW for new account, GUEST for guest): ")
sess.Color(session.AnsiReset)
name, err := sess.ReadLine("", maxNameLen, inputTimeout)
if err != nil {
return nil, err
}
name = strings.TrimSpace(name)
if name == "" {
tries++
continue
}
upper := strings.ToUpper(name)
// Guest access
if upper == "GUEST" {
return guestLogin(sess, cfg), nil
}
// New account
if upper == "NEW" {
if firstRun {
return newAccount(sess, db, cfg, true)
}
return newAccount(sess, db, cfg, false)
}
// Existing user login
user, err := db.GetUserByName(name)
if err != nil {
return nil, fmt.Errorf("looking up user: %w", err)
}
if user == nil {
sess.WriteString("No account with that name.\r\n")
// Offer to create if name looks intentional
yes, err := sess.Confirm("Create a new account? (Y/N) ", inputTimeout)
if err != nil {
return nil, err
}
if yes {
if firstRun {
return newAccountWithName(sess, db, cfg, name, true)
}
return newAccountWithName(sess, db, cfg, name, false)
}
tries++
continue
}
// User found — verify password
result, err := passwordLogin(sess, db, cfg, user)
if err != nil {
return nil, err
}
if result != nil {
return result, nil
}
// Password failed
tries++
}
}
// passwordLogin prompts for a password and verifies it against the
// stored bcrypt hash. Returns nil result if password is wrong.
func passwordLogin(sess *session.Session, db store.Store, cfg *config.Config, user *models.User) (*Result, error) {
sess.Color(session.AnsiFgCyan)
sess.WriteString("Password: ")
sess.Color(session.AnsiReset)
pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(pass)); err != nil {
sess.WriteString("Invalid password.\r\n\r\n")
return nil, nil // Signal: wrong password, but not a fatal error
}
// Successful login — update last-on time
now := time.Now()
user.LastOn = &now
user.TimeUsed = 0 // Reset per-session time
if err := db.UpdateUser(user); err != nil {
return nil, fmt.Errorf("updating user: %w", err)
}
sess.NewLine()
sess.Color(session.AnsiFgGreen)
sess.Printf("Welcome back, %s!\r\n", user.Name)
sess.Color(session.AnsiReset)
// Set session time limit from user record
sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second
return &Result{User: user}, nil
}
// guestLogin creates a transient guest session with no database record.
// This replaces New_Account_Sequence() from the original, which despite
// its name actually created a guest session (not a saved account).
func guestLogin(sess *session.Session, cfg *config.Config) *Result {
guest := &models.User{
Name: "Guest",
SecStatus: cfg.Users.GuestSecurity.Status,
SecBoard: cfg.Users.GuestSecurity.Board,
SecLibrary: cfg.Users.GuestSecurity.Library,
SecBulletin: cfg.Users.GuestSecurity.Bulletin,
TimeLimit: int64(cfg.Users.GuestTimeLimit),
Active: true,
}
sess.TimeLimit = time.Duration(cfg.Users.GuestTimeLimit) * time.Second
// Display guest login screen if available
sess.SendFile(cfg.System.Screens + "guest.ans")
sess.Color(session.AnsiFgYellow)
sess.WriteString("Logged in as Guest.\r\n")
sess.Color(session.AnsiReset)
return &Result{User: guest, IsGuest: true}
}
// newAccount runs the new account creation flow.
func newAccount(sess *session.Session, db store.Store, cfg *config.Config, isSysop bool) (*Result, error) {
sess.NewLine()
// Display new user screen if available
sess.SendFile(cfg.System.Screens + "newuser.ans")
sess.Color(session.AnsiFgCyan)
sess.WriteString("Choose a username: ")
sess.Color(session.AnsiReset)
name, err := sess.ReadLine("", maxNameLen, inputTimeout)
if err != nil {
return nil, err
}
return createAccount(sess, db, cfg, strings.TrimSpace(name), isSysop)
}
// newAccountWithName runs account creation with a pre-chosen name
// (when the user typed a name that didn't exist and said "yes" to
// creating it).
func newAccountWithName(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) {
sess.NewLine()
sess.SendFile(cfg.System.Screens + "newuser.ans")
return createAccount(sess, db, cfg, name, isSysop)
}
// createAccount handles the shared account creation logic.
func createAccount(sess *session.Session, db store.Store, cfg *config.Config, name string, isSysop bool) (*Result, error) {
// Validate name
if err := validateName(name); err != nil {
sess.Printf("%s\r\n", err)
return guestLogin(sess, cfg), nil
}
// Check for duplicate
existing, err := db.GetUserByName(name)
if err != nil {
return nil, fmt.Errorf("checking name: %w", err)
}
if existing != nil {
sess.WriteString("That name is already taken.\r\n")
return guestLogin(sess, cfg), nil
}
// Check account limit
count, err := db.CountUsers()
if err != nil {
return nil, fmt.Errorf("counting users: %w", err)
}
if count >= cfg.Users.MaxAccounts {
sess.WriteString("Sorry, maximum accounts reached.\r\n")
return guestLogin(sess, cfg), nil
}
// Get password
sess.Color(session.AnsiFgCyan)
sess.WriteString("Choose a password: ")
sess.Color(session.AnsiReset)
pass, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if len(pass) < minPassLen {
sess.Printf("Password must be at least %d characters.\r\n", minPassLen)
return guestLogin(sess, cfg), nil
}
// Confirm password
sess.Color(session.AnsiFgCyan)
sess.WriteString("Confirm password: ")
sess.Color(session.AnsiReset)
confirm, err := sess.ReadLineNoEcho(maxPassLen, inputTimeout)
if err != nil {
return nil, err
}
if pass != confirm {
sess.WriteString("Passwords do not match.\r\n")
return guestLogin(sess, cfg), nil
}
// Hash the password
hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcryptCost)
if err != nil {
return nil, fmt.Errorf("hashing password: %w", err)
}
// Build the user record
now := time.Now()
user := &models.User{
Name: name,
PasswordHash: string(hash),
Active: true,
LastOn: &now,
}
if isSysop {
// First user ever — full sysop privileges
user.SecStatus = 255
user.SecBoard = 255
user.SecLibrary = 255
user.SecBulletin = 255
user.TimeLimit = 86400 // 24 hours — effectively unlimited
} else {
// Normal new user — gets "new" security tier
user.SecStatus = cfg.Users.NewSecurity.Status
user.SecBoard = cfg.Users.NewSecurity.Board
user.SecLibrary = cfg.Users.NewSecurity.Library
user.SecBulletin = cfg.Users.NewSecurity.Bulletin
user.TimeLimit = int64(cfg.Users.NewTimeLimit)
}
if err := db.CreateUser(user); err != nil {
// Could be a race condition on duplicate name
sess.WriteString("Error creating account. Please try again.\r\n")
return guestLogin(sess, cfg), nil
}
sess.TimeLimit = time.Duration(user.TimeLimit) * time.Second
sess.NewLine()
sess.Color(session.AnsiFgGreen, session.AnsiBold)
if isSysop {
sess.Printf("Sysop account created: %s (ID #%d)\r\n", user.Name, user.ID)
} else {
sess.Printf("Account created: %s (ID #%d)\r\n", user.Name, user.ID)
sess.Color(session.AnsiFgYellow)
sess.WriteString("Your account is NEW and must be validated by the Sysop\r\n")
sess.WriteString("for full access.\r\n")
}
sess.Color(session.AnsiReset)
sess.NewLine()
return &Result{User: user, IsNew: !isSysop}, nil
}
// validateName checks that a username meets requirements.
func validateName(name string) error {
if len(name) < 2 {
return fmt.Errorf("name must be at least 2 characters")
}
if len(name) > maxNameLen {
return fmt.Errorf("name must be %d characters or fewer", maxNameLen)
}
upper := strings.ToUpper(name)
if upper == "GUEST" || upper == "NEW" || upper == "SYSOP" {
return fmt.Errorf("%q is a reserved name", name)
}
// Must contain at least one letter
hasLetter := false
for _, r := range name {
if unicode.IsLetter(r) {
hasLetter = true
}
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != ' ' && r != '-' && r != '_' && r != '.' {
return fmt.Errorf("name can only contain letters, numbers, spaces, hyphens, underscores, and periods")
}
}
if !hasLetter {
return fmt.Errorf("name must contain at least one letter")
}
return nil
}
// HashPassword generates a bcrypt hash for the given password.
// Exported for use by admin tools and tests.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword verifies a password against a bcrypt hash.
// Exported for use by admin tools and tests.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}