397 lines
11 KiB
Go
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
|
|
}
|