From 9427535eb51a410049653e20bc818861bacf0928 Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Sat, 27 Sep 2025 23:07:16 -0600 Subject: [PATCH] got most of the login functionality together --- database/audit.go | 96 +++++++++++++++++++++++++++++++++++++++++++++ database/user.go | 96 +++++++++++++++++++++++++++++++++++++++++++++ login.go | 27 +++++++++++-- ui/amcontext.go | 15 +++++++ ui/dialog.go | 14 +++++++ ui/templates.go | 24 ++++++++++++ ui/views/dialog.jet | 14 +++++++ ui/views/error.jet | 2 +- 8 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 database/audit.go diff --git a/database/audit.go b/database/audit.go new file mode 100644 index 0000000..1632767 --- /dev/null +++ b/database/audit.go @@ -0,0 +1,96 @@ +/* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +// The database package contains database management and storage logic. +package database + +import ( + "fmt" + "time" +) + +// AuditRecord holds an audit record instance. +type AuditRecord struct { + Record int64 `db:"record"` + OnDate time.Time `db:"on_date"` + Event int32 `db:"event"` + Uid int32 `db:"uid"` + CommId int32 `db:"commid"` + IP *string `db:"ip"` + Data1 *string `db:"data1"` + Data2 *string `db:"data2"` + Data3 *string `db:"data3"` + Data4 *string `db:"data4"` +} + +// These are the audit record types. +const ( + AuditPublishToFrontPage = 1 + AuditLoginOK = 101 + AuditLoginFail = 102 + AuditAccountCreated = 103 + AuditVerifyEmailOK = 104 + AuditVerifyEmailFail = 105 + AuditSetUserContactInfo = 106 + AuditResendEmailConfirm = 107 + AuditChangePassword = 108 + AuditAdminSetUserContectInfo = 109 + AuditAdminChangeUserPassword = 110 + AuditAdminChangeUserAccount = 111 + AuditAdminSetAccountSecurity = 112 + AuditAdminLockUnlockAccount = 113 +) + +/* AmNewAudit creates a new audit record. + * Parameters: + * rectype - Audit record type. + * uid - User ID of the user. + * ip - User's IP address. + * data - Argument data values for the audit record. + * Returns: + * The audit record pointer. + */ +func AmNewAudit(rectype int32, uid int32, ip string, data ...string) *AuditRecord { + rc := AuditRecord{Event: rectype, Uid: uid, CommId: 0} + if len(ip) > 0 { + rc.IP = &ip + } + if data != nil { + l := len(data) + if l > 0 { + rc.Data1 = &(data[0]) + } + if l > 1 { + rc.Data2 = &(data[1]) + } + if l > 2 { + rc.Data3 = &(data[2]) + } + if l > 3 { + rc.Data4 = &(data[3]) + } + } + return &rc +} + +// Store stores the audit record in the database. +func (ar *AuditRecord) Store() error { + if ar.Record > 0 { + return fmt.Errorf("audit record %d already stored", ar.Record) + } + moment := time.Now() + rs, err := amdb.Exec(`INSERT INTO audit (on_date, event, uid, commid, ip, data1, data2, data3, data4) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, moment, ar.Event, ar.Uid, ar.CommId, ar.IP, + ar.Data1, ar.Data2, ar.Data3, ar.Data4) + if err != nil { + return err + } + ar.Record, _ = rs.LastInsertId() + ar.OnDate = moment + return nil +} diff --git a/database/user.go b/database/user.go index 1bf5e6f..44df3e9 100644 --- a/database/user.go +++ b/database/user.go @@ -10,11 +10,15 @@ package database import ( + "crypto/sha1" + "encoding/hex" + "errors" "fmt" "sync" "time" lru "github.com/hashicorp/golang-lru" + log "github.com/sirupsen/logrus" ) // User represents a user in the Amsterdam database. @@ -83,6 +87,32 @@ func AmGetUser(uid int32) (*User, error) { return rc.(*User), err } +/* AmGetUserByName returns a reference to the specified user. + * Parameters: + * name - The username of the user. + * Returns: + * Pointer to User containing user data, or nil + * Standard Go error status + */ +func AmGetUserByName(name string) (*User, error) { + var dbdata []User + err := amdb.Select(&dbdata, "SELECT * FROM users WHERE username = ?", name) + if err != nil { + return nil, err + } + if len(dbdata) > 1 { + return nil, fmt.Errorf("AmGetUserByName(\"%s\"): too many responses(%d)", name, len(dbdata)) + } + getUserMutex.Lock() + defer getUserMutex.Unlock() + rc, ok := userCache.Get(dbdata[0].Uid) + if !ok { + rc = &(dbdata[0]) + userCache.Add(dbdata[0].Uid, rc) + } + return rc.(*User), nil +} + // getAnonUserID retrieves the UID of the "anonymous" user from the database. func getAnonUserID() (int32, error) { if anonUid < 0 { @@ -130,3 +160,69 @@ func AmGetAnonUser() (*User, error) { } return rc, err } + +// hashPassword hashes the password value. +func hashPassword(password string) string { + if len(password) == 0 { + return "" + } + hasher := sha1.New() + hasher.Write([]byte(password)) + hashBytes := hasher.Sum(nil) + return hex.EncodeToString(hashBytes) +} + +// touchUser updates the last access time for the user. +func touchUser(user *User) { + user.Mutex.Lock() + defer user.Mutex.Unlock() + moment := time.Now() + _, _ = amdb.Exec("UPDATE user SET lastaccess = ? WHERE uid = ?", moment, user.Uid) + user.LastAccess = &moment +} + +/* AmAuthenticateUser authenticates a user by name and password. + * Parameters: + * name - The user name to try. + * password - The password to try. + * remote_ip - The remote IP address, for audit records. + * Returns: + * The User pointer if authenticated, or nil if not. + * Standard Go error status. + */ +func AmAuthenticateUser(name string, password string, remote_ip string) (*User, error) { + log.Debugf("AmAuthenicate() authenticating user %s...", name) + var ar *AuditRecord = nil + defer func() { + if ar != nil { + go ar.Store() + } + }() + + user, err := AmGetUserByName(name) + if err != nil { + log.Error("...user not found") + ar = AmNewAudit(AuditLoginFail, 0, remote_ip, fmt.Sprintf("Bad username: %s", name)) + return nil, errors.New("the user account you have specified does not exist; please try again") + } + if user.IsAnon { + log.Error("...user is the Anonymous Honyak, can't explicitly log in") + ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Anonymous user") + return nil, errors.New("this account cannot be explicitly logged into; please try again") + } + if user.Lockout { + log.Error("...user is locked out by the admin") + ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Account locked out") + return nil, errors.New("this account has been administratively locked; please contact the system administrator for assistance") + } + h := hashPassword(password) + if h != user.Passhash { + log.Warn("...invalid password") + ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Bad password") + return nil, errors.New("the password you have specified is incorrect; please try again") + } + log.Debug("...authenticated") + touchUser(user) + ar = AmNewAudit(AuditLoginOK, user.Uid, remote_ip) + return user, nil +} diff --git a/login.go b/login.go index 6bd90b7..3bb790b 100644 --- a/login.go +++ b/login.go @@ -10,8 +10,10 @@ package main import ( + "errors" "fmt" + "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" ) @@ -59,6 +61,10 @@ func Login(ctxt ui.AmContext) (string, any, error) { if target == "" { target = "/" } + // If user is already logged in, this is a no-op. + if !ctxt.CurrentUser().IsAnon { + return "redirect", target, nil + } action := dlg.WhichButton(ctxt) if action == "cancel" { @@ -66,9 +72,22 @@ func Login(ctxt ui.AmContext) (string, any, error) { } if action == "remind" { // TODO: send password reminder - return dlg.Render(ctxt) + dlg.Field("pass").Value = "" + return dlg.RenderError(ctxt, "Password reminder has been sent to your E-mail address.") } - + if action == "login" { + // authenticate the user + user, uerr := database.AmAuthenticateUser(dlg.Field("user").Value, dlg.Field("pass").Value, ctxt.RemoteIP()) + if uerr != nil { + dlg.Field("pass").Value = "" + return dlg.RenderError(ctxt, uerr.Error()) + } + ctxt.ReplaceUser(user) + // TODO: cookie set if required + // TODO: bounce to E-mail verify if we can do so + return "redirect", target, nil + } + err = errors.New("no known button click on POST to login function") } return ui.ErrorPage(ctxt, err) } @@ -90,7 +109,7 @@ func NewAccountUserAgreement(ctxt ui.AmContext) (string, any, error) { // If user is already logged in, this is an error. if !ctxt.CurrentUser().IsAnon { - return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a new account while logged in on an existing one. You must log out first.")) + return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first")) } ctxt.VarMap().Set("target", target) @@ -115,7 +134,7 @@ func NewAccountForm(ctxt ui.AmContext) (string, any, error) { // If user is already logged in, this is an error. if !ctxt.CurrentUser().IsAnon { - return ui.ErrorPage(ctxt, fmt.Errorf("You cannot create a new account while logged in on an existing one. You must log out first.")) + return ui.ErrorPage(ctxt, fmt.Errorf("you cannot create a new account while logged in on an existing one. You must log out first")) } dlg, err := ui.AmLoadDialog("newaccount") diff --git a/ui/amcontext.go b/ui/amcontext.go index 516e91a..9e6fbe1 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -32,7 +32,9 @@ type AmContext interface { RC() int OutputType() string Parameter(string) string + RemoteIP() string Render(string) error + ReplaceUser(*database.User) SubRender(string) ([]byte, error) Session() *sessions.Session SetOutputType(string) @@ -121,6 +123,11 @@ func (c *amContext) Parameter(name string) string { return rc } +// RemoteIP returns the remote IP address. +func (c *amContext) RemoteIP() string { + return c.echoContext.RealIP() +} + /* Render renders a template to the output. Called at the top level only. * Parameters: * name = The name of the tempate to be rendered. @@ -131,6 +138,14 @@ func (c *amContext) Render(name string) error { return c.echoContext.Render(c.httprc, name, c) } +/* ReplaceUser replaces the current user in the context. + * Parameters: + * u - New user to associate with the context. + */ +func (c *amContext) ReplaceUser(u *database.User) { + c.session.Values["user_id"] = u.Uid +} + // Scratchpad returns the per-request scratchpad for values. func (c *amContext) Scratchpad() map[string]any { if c.scratchpad == nil { diff --git a/ui/dialog.go b/ui/dialog.go index 0b3672a..6226c25 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -124,6 +124,20 @@ func (d *Dialog) Render(ctxt AmContext) (string, any, error) { return "framed_template", "dialog.jet", nil } +/* RenderError sets up the rendering parameters to send this dialog to the output with an error message. + * Parameters: + * ctxt - The AmContext for this request. + * errormessage - The error message to be displayed. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + * Standard Go error status. + */ +func (d *Dialog) RenderError(ctxt AmContext, errormessage string) (string, any, error) { + ctxt.VarMap().Set("amsterdam_errorMessage", errormessage) + return d.Render(ctxt) +} + /* LoadFromForm loads the values in a dialog from the form fields in the request. * Parameters: * ctxt - The AmContext for this request. diff --git a/ui/templates.go b/ui/templates.go index 75153cf..52ca69f 100644 --- a/ui/templates.go +++ b/ui/templates.go @@ -21,6 +21,7 @@ import ( "strings" "sync" "time" + "unicode" "git.erbosoft.com/amy/amsterdam/config" "github.com/CloudyKit/jet/v6" @@ -36,10 +37,13 @@ var static_views embed.FS // views is the main Jet template repository. var views *jet.Set +// cachedCountryList is the cached country list after sorting. var cachedCountryList []countries.CountryCode = nil +// countryListMutex control access to internalGetCountryList. var countryListMutex sync.Mutex +// internalGetCountryList is a wrapper around countries.All() that sorts it by country name. func internalGetCountryList() []countries.CountryCode { countryListMutex.Lock() defer countryListMutex.Unlock() @@ -126,6 +130,21 @@ func makeYearRange(a jet.Arguments) reflect.Value { } } +/* CapitalizeString changes the first character of trhe string to a capital. + * Parameters: + * s - The string to be capitalized. + * Returns: + * The capitalized string. + */ +func CapitalizeString(s string) string { + runes := []rune(s) + if len(runes) > 0 { + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) + } + return "" +} + // SetupTemplates is called to set up the template renderer after the configuration is loaded. func SetupTemplates() { views = jet.NewSet( @@ -143,6 +162,11 @@ func SetupTemplates() { views.AddGlobalFunc("MakeIntRange", makeIntRange) views.AddGlobalFunc("MakeYearRange", makeYearRange) + views.AddGlobalFunc("CapitalizeString", func(a jet.Arguments) reflect.Value { + s := a.Get(0).Convert(reflect.TypeFor[string]()).String() + return reflect.ValueOf(CapitalizeString(s)) + }) + // preload the country list in the background go internalGetCountryList() } diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet index fb3ecfa..9786cf1 100644 --- a/ui/views/dialog.jet +++ b/ui/views/dialog.jet @@ -25,6 +25,20 @@

Required fields are marked with a *.

{{ end }} + {{ if isset(amsterdam_errorMessage) }} + + + {{ end }} +
{{ range amsterdam_dialog.Fields }} diff --git a/ui/views/error.jet b/ui/views/error.jet index d165483..e220937 100644 --- a/ui/views/error.jet +++ b/ui/views/error.jet @@ -12,7 +12,7 @@

Amsterdam Internal Server Error


- The Amsterdam server encountered an error: {{ error }} + The Amsterdam server encountered an error: {{ CapitalizeString(error) }}.

Click here to return to the home page.