// 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 }