added cookie login

This commit is contained in:
2025-10-04 12:49:04 -06:00
parent f728eb21b0
commit 3ef8d6b9a6
7 changed files with 172 additions and 15 deletions
+14 -3
View File
@@ -47,9 +47,11 @@ func (*AmCLI) Version() string {
// AmConfig holds the configuration of the application as read from YAML. // AmConfig holds the configuration of the application as read from YAML.
type AmConfig struct { type AmConfig struct {
Site struct { Site struct {
Title string `yaml:"title"` Title string `yaml:"title"`
TopRefresh int `yaml:"topRefresh"` TopRefresh int `yaml:"topRefresh"`
UserAgreement struct { LoginCookieName string `yaml:"loginCookieName"`
LoginCookieAge int `yaml:"loginCookieAge"`
UserAgreement struct {
Title string `yaml:"title"` Title string `yaml:"title"`
Text string `yaml:"text"` Text string `yaml:"text"`
} `yaml:"userAgreement"` } `yaml:"userAgreement"`
@@ -106,6 +108,13 @@ func overlayString(loaded string, defaulted string) string {
return loaded return loaded
} }
/* overlayInt is a helper that takes a loaded or defaulted integer and returns it.
* Parameters:
* loaded - The integer loaded from a configuration file.
* defaulted - The default value of this integer.
* Returns:
* loaded if it's not empty, otherwise defaulted.
*/
func overlayInt(loaded int, defaulted int) int { func overlayInt(loaded int, defaulted int) int {
if loaded != 0 { if loaded != 0 {
return loaded return loaded
@@ -122,6 +131,8 @@ func overlayInt(loaded int, defaulted int) int {
func overlayConfig(dest *AmConfig, loaded *AmConfig, defaults *AmConfig) { func overlayConfig(dest *AmConfig, loaded *AmConfig, defaults *AmConfig) {
dest.Site.Title = overlayString(loaded.Site.Title, defaults.Site.Title) dest.Site.Title = overlayString(loaded.Site.Title, defaults.Site.Title)
dest.Site.TopRefresh = overlayInt(loaded.Site.TopRefresh, defaults.Site.TopRefresh) dest.Site.TopRefresh = overlayInt(loaded.Site.TopRefresh, defaults.Site.TopRefresh)
dest.Site.LoginCookieName = overlayString(loaded.Site.LoginCookieName, defaults.Site.LoginCookieName)
dest.Site.LoginCookieAge = overlayInt(loaded.Site.LoginCookieAge, defaults.Site.LoginCookieAge)
dest.Site.UserAgreement.Title = overlayString(loaded.Site.UserAgreement.Title, defaults.Site.UserAgreement.Title) dest.Site.UserAgreement.Title = overlayString(loaded.Site.UserAgreement.Title, defaults.Site.UserAgreement.Title)
dest.Site.UserAgreement.Text = overlayString(loaded.Site.UserAgreement.Text, defaults.Site.UserAgreement.Text) dest.Site.UserAgreement.Text = overlayString(loaded.Site.UserAgreement.Text, defaults.Site.UserAgreement.Text)
dest.Database.Driver = overlayString(loaded.Database.Driver, defaults.Database.Driver) dest.Database.Driver = overlayString(loaded.Database.Driver, defaults.Database.Driver)
+2
View File
@@ -9,6 +9,8 @@
site: site:
title: "Amsterdam Web Communities System" title: "Amsterdam Web Communities System"
topRefresh: 300 topRefresh: 300
loginCookieName: AmsterdamAuth
loginCookieAge: 365
userAgreement: userAgreement:
title: "Amsterdam User Agreement" title: "Amsterdam User Agreement"
text: > text: >
+84 -6
View File
@@ -15,6 +15,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"hash/crc32" "hash/crc32"
"strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -76,6 +78,9 @@ func (u *User) ContactInfo() (*ContactInfo, error) {
* Standard Go error status. * Standard Go error status.
*/ */
func (u *User) NewAuthToken() (string, error) { func (u *User) NewAuthToken() (string, error) {
if u.IsAnon {
return "", errors.New("cannot generate token for anonymous user")
}
u.Mutex.Lock() u.Mutex.Lock()
defer u.Mutex.Unlock() defer u.Mutex.Unlock()
newToken := util.GenerateRandomAuthString() newToken := util.GenerateRandomAuthString()
@@ -217,7 +222,7 @@ func touchUser(user *User) {
* The User pointer if authenticated, or nil if not. * The User pointer if authenticated, or nil if not.
* Standard Go error status. * Standard Go error status.
*/ */
func AmAuthenticateUser(name string, password string, remote_ip string) (*User, error) { func AmAuthenticateUser(name string, password string, remoteIP string) (*User, error) {
log.Debugf("AmAuthenicate() authenticating user %s...", name) log.Debugf("AmAuthenicate() authenticating user %s...", name)
var ar *AuditRecord = nil var ar *AuditRecord = nil
defer func() { defer func() {
@@ -229,27 +234,100 @@ func AmAuthenticateUser(name string, password string, remote_ip string) (*User,
user, err := AmGetUserByName(name) user, err := AmGetUserByName(name)
if err != nil { if err != nil {
log.Error("...user not found") log.Error("...user not found")
ar = AmNewAudit(AuditLoginFail, 0, remote_ip, fmt.Sprintf("Bad username: %s", name)) ar = AmNewAudit(AuditLoginFail, 0, remoteIP, fmt.Sprintf("Bad username: %s", name))
return nil, errors.New("the user account you have specified does not exist; please try again") return nil, errors.New("the user account you have specified does not exist; please try again")
} }
if user.IsAnon { if user.IsAnon {
log.Error("...user is the Anonymous Honyak, can't explicitly log in") log.Error("...user is the Anonymous Honyak, can't explicitly log in")
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Anonymous user") ar = AmNewAudit(AuditLoginFail, user.Uid, remoteIP, "Anonymous user")
return nil, errors.New("this account cannot be explicitly logged into; please try again") return nil, errors.New("this account cannot be explicitly logged into; please try again")
} }
if user.Lockout { if user.Lockout {
log.Error("...user is locked out by the admin") log.Error("...user is locked out by the admin")
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Account locked out") ar = AmNewAudit(AuditLoginFail, user.Uid, remoteIP, "Account locked out")
return nil, errors.New("this account has been administratively locked; please contact the system administrator for assistance") return nil, errors.New("this account has been administratively locked; please contact the system administrator for assistance")
} }
h := hashPassword(password) h := hashPassword(password)
if h != user.Passhash { if h != user.Passhash {
log.Warn("...invalid password") log.Warn("...invalid password")
ar = AmNewAudit(AuditLoginFail, user.Uid, remote_ip, "Bad password") ar = AmNewAudit(AuditLoginFail, user.Uid, remoteIP, "Bad password")
return nil, errors.New("the password you have specified is incorrect; please try again") return nil, errors.New("the password you have specified is incorrect; please try again")
} }
log.Debug("...authenticated") log.Debug("...authenticated")
touchUser(user) touchUser(user)
ar = AmNewAudit(AuditLoginOK, user.Uid, remote_ip) ar = AmNewAudit(AuditLoginOK, user.Uid, remoteIP)
return user, nil
}
// crackAuthString validates an auth string and returns its UID and auth token.
func crackAuthString(authString string) (int32, string, error) {
log.Debug("Decoding authString " + authString)
if !strings.HasPrefix(authString, "AQAT:") {
return 0, "", errors.New("prefix not valid")
}
parms := strings.Split(authString[5:], "|")
n1, err := strconv.ParseInt(parms[0], 10, 32)
if err != nil {
return 0, "", fmt.Errorf("invalid UID field: %v", err)
}
uid := int32(n1)
n2, err2 := strconv.ParseUint(parms[2], 10, 32)
if err2 != nil {
return 0, "", fmt.Errorf("invalid checkvalue field: %v", err2)
}
cv1 := uint32(n2)
cv2 := uint32(uid) ^ crc32.ChecksumIEEE([]byte(parms[1]))
if cv1 != cv2 {
return 0, "", errors.New("checkvalues do not match")
}
return uid, parms[1], nil
}
/* AmAuthenticateUserByToken authenticates a user via the stored cookie authentication string.
* Parameters:
* authString - The stored cookie authentication string.
* remoteIP - The remote IP address wheter trhe user is logging in from.
* Returns:
* Pointer to the authenticated User, or nil.
* Standard Go error status.
*/
func AmAuthenticateUserByToken(authString string, remoteIP string) (*User, error) {
var ar *AuditRecord = nil
defer func() {
if ar != nil {
go ar.Store()
}
}()
uid, token, err := crackAuthString(authString)
if err != nil {
return nil, fmt.Errorf("authString not valid, ignored: %v", err)
}
var user *User
user, err = AmGetUser(uid)
if err != nil {
log.Error("...user not found")
ar = AmNewAudit(AuditLoginFail, 0, remoteIP, fmt.Sprintf("Bad uid: %d", uid))
return nil, fmt.Errorf("uid %d not found, ignore: %v", uid, err)
}
log.Debugf("AmAuthenicateUserByToken() authenticating user %d...", uid)
if user.IsAnon {
log.Error("...user is the Anonymous Honyak, can't explicitly log in")
ar = AmNewAudit(AuditLoginFail, user.Uid, remoteIP, "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, remoteIP, "Account locked out")
return nil, errors.New("this account has been administratively locked; please contact the system administrator for assistance")
}
if user.Tokenauth == nil || *(user.Tokenauth) != token {
log.Error("...token mismatch")
ar = AmNewAudit(AuditLoginFail, user.Uid, remoteIP, "Token mismatch")
return nil, errors.New("token mismatch")
}
log.Debug("...authenticated")
touchUser(user)
ar = AmNewAudit(AuditLoginOK, user.Uid, remoteIP)
return user, nil return user, nil
} }
+10 -2
View File
@@ -16,6 +16,7 @@ import (
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/email"
"git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/ui"
"github.com/labstack/gommon/log"
) )
/* LoginForm renders the Amsterdam login form. /* LoginForm renders the Amsterdam login form.
@@ -108,7 +109,14 @@ func Login(ctxt ui.AmContext) (string, any, error) {
} }
ctxt.ReplaceUser(user) ctxt.ReplaceUser(user)
if dlg.Field("saveme").IsChecked() { if dlg.Field("saveme").IsChecked() {
// TODO: cookie set // create and save an authentication token
authString, cerr := user.NewAuthToken()
if cerr == nil {
ctxt.SetLoginCookie(authString)
} else {
log.Errorf("unable to generate auth string for uid %d: %v", user.Uid, cerr)
}
} }
// TODO: bounce to E-mail verify if we can do so // TODO: bounce to E-mail verify if we can do so
return "redirect", target, nil return "redirect", target, nil
@@ -135,7 +143,7 @@ func Logout(ctxt ui.AmContext) (string, any, error) {
} }
if !ctxt.CurrentUser().IsAnon { if !ctxt.CurrentUser().IsAnon {
// TODO: erase login cookie ctxt.ClearLoginCookie()
ctxt.ClearSession() ctxt.ClearSession()
} }
return "redirect", target, nil return "redirect", target, nil
+29 -2
View File
@@ -14,7 +14,9 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"strconv" "strconv"
"time"
"git.erbosoft.com/amy/amsterdam/config"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"github.com/CloudyKit/jet/v6" "github.com/CloudyKit/jet/v6"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@@ -25,6 +27,7 @@ import (
// AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality. // AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality.
type AmContext interface { type AmContext interface {
ClearLoginCookie()
ClearSession() ClearSession()
CurrentUser() *database.User CurrentUser() *database.User
CurrentUserId() int32 CurrentUserId() int32
@@ -39,6 +42,7 @@ type AmContext interface {
ReplaceUser(*database.User) ReplaceUser(*database.User)
SaveSession() error SaveSession() error
SubRender(string) ([]byte, error) SubRender(string) ([]byte, error)
SetLoginCookie(string)
SetOutputType(string) SetOutputType(string)
SetRC(int) SetRC(int)
GetScratch(string) any GetScratch(string) any
@@ -57,12 +61,22 @@ type amContext struct {
session *sessions.Session session *sessions.Session
} }
// ClearLoginCookie overwrites and removes the login cookie.
func (c *amContext) ClearLoginCookie() {
cookie := new(http.Cookie)
cookie.Name = config.GlobalConfig.Site.LoginCookieName
cookie.Value = ""
cookie.Path = "/"
cookie.Expires = time.Now()
c.echoContext.SetCookie(cookie)
}
// ClearSession clears the current session. // ClearSession clears the current session.
func (c *amContext) ClearSession() { func (c *amContext) ClearSession() {
for k := range c.session.Values { for k := range c.session.Values {
delete(c.session.Values, k) delete(c.session.Values, k)
} }
SetupAmSession(c.session) setupAmSession(c.session)
} }
// CurrentUser returns the current user from the session. // CurrentUser returns the current user from the session.
@@ -191,6 +205,19 @@ func (c *amContext) SubRender(name string) ([]byte, error) {
return buf.Bytes(), err return buf.Bytes(), err
} }
/* SetLoginCookie adds the login cookie to the result output.
* Parameters:
* auth - The auth string to set.
*/
func (c *amContext) SetLoginCookie(auth string) {
cookie := new(http.Cookie)
cookie.Name = config.GlobalConfig.Site.LoginCookieName
cookie.Value = auth
cookie.Path = "/"
cookie.Expires = time.Now().AddDate(0, 0, config.GlobalConfig.Site.LoginCookieAge)
c.echoContext.SetCookie(cookie)
}
// SetOutputType sets the MIME output type for the current operation. // SetOutputType sets the MIME output type for the current operation.
func (c *amContext) SetOutputType(typ string) { func (c *amContext) SetOutputType(typ string) {
c.outputType = typ c.outputType = typ
@@ -253,7 +280,7 @@ func NewAmContext(ctxt echo.Context) (AmContext, error) {
rc.session = sess rc.session = sess
sess.Options = defoptions sess.Options = defoptions
if sess.IsNew { if sess.IsNew {
SetupAmSession(sess) setupAmSession(sess)
} else { } else {
log.Debugf("took the not-new-session path") log.Debugf("took the not-new-session path")
} }
+31
View File
@@ -14,8 +14,10 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"git.erbosoft.com/amy/amsterdam/config"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
) )
func sendPageData(ctxt echo.Context, amctxt AmContext, command string, data any) error { func sendPageData(ctxt echo.Context, amctxt AmContext, command string, data any) error {
@@ -63,11 +65,14 @@ func ErrorPage(ctxt AmContext, input_err error) (string, any, error) {
*/ */
func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc { func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc {
return func(ctxt echo.Context) error { return func(ctxt echo.Context) error {
// Create the AmContext.
amctxt, aerr := NewAmContext(ctxt) amctxt, aerr := NewAmContext(ctxt)
if aerr != nil { if aerr != nil {
ctxt.Logger().Errorf("Session creation error: %v", aerr) ctxt.Logger().Errorf("Session creation error: %v", aerr)
return aerr return aerr
} }
// Check IP banning.
banmsg, banerr := database.AmTestIPBan(ctxt.RealIP()) banmsg, banerr := database.AmTestIPBan(ctxt.RealIP())
if banerr != nil { if banerr != nil {
ctxt.Logger().Warnf("address %s could not be tested: %v", ctxt.RealIP(), banerr) ctxt.Logger().Warnf("address %s could not be tested: %v", ctxt.RealIP(), banerr)
@@ -75,8 +80,33 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc {
} else if banmsg != "" { } else if banmsg != "" {
amctxt.VarMap().Set("amsterdam_pageTitle", "IP Address Banned") amctxt.VarMap().Set("amsterdam_pageTitle", "IP Address Banned")
amctxt.VarMap().Set("message", banmsg) amctxt.VarMap().Set("message", banmsg)
amctxt.SetRC(http.StatusForbidden)
return sendPageData(ctxt, amctxt, "framed_template", "ipban.jet") return sendPageData(ctxt, amctxt, "framed_template", "ipban.jet")
} }
// Check for cookie login.
if amctxt.CurrentUser().IsAnon {
cookie, cerr := ctxt.Cookie(config.GlobalConfig.Site.LoginCookieName)
if cerr == nil {
var user *database.User
user, cerr = database.AmAuthenticateUserByToken(cookie.Value, ctxt.RealIP())
if cerr == nil {
// log the user in and rotate login cookie
amctxt.ReplaceUser(user)
var newToken string
if newToken, cerr = user.NewAuthToken(); cerr == nil {
amctxt.SetLoginCookie(newToken)
} else {
log.Warnf("unable to rotate login cookie: %v", cerr)
}
} else {
log.Errorf("login cookie bogus, do not use: %v", cerr)
amctxt.ClearLoginCookie()
}
}
}
// Exec the wrapped function.
what, rc, err := myfunc(amctxt) what, rc, err := myfunc(amctxt)
if err == nil { if err == nil {
if err = amctxt.SaveSession(); err != nil { if err = amctxt.SaveSession(); err != nil {
@@ -90,6 +120,7 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc {
} else { } else {
ctxt.Logger().Errorf("Page function error: %v", err) ctxt.Logger().Errorf("Page function error: %v", err)
_, rc, _ = ErrorPage(amctxt, err) _, rc, _ = ErrorPage(amctxt, err)
amctxt.SetRC(http.StatusInternalServerError)
newerr := sendPageData(ctxt, amctxt, "framed_template", rc) newerr := sendPageData(ctxt, amctxt, "framed_template", rc)
err = newerr err = newerr
} }
+2 -2
View File
@@ -26,8 +26,8 @@ func SetupSessionManager() {
SessionStore = memstore.NewMemStore([]byte(config.GlobalConfig.Rendering.CookieKey)) SessionStore = memstore.NewMemStore([]byte(config.GlobalConfig.Rendering.CookieKey))
} }
// SetupAmSession sets up a newly created Amsterdam session. // setupAmSession sets up a newly created Amsterdam session.
func SetupAmSession(session *sessions.Session) { func setupAmSession(session *sessions.Session) {
u, err := database.AmGetAnonUser() u, err := database.AmGetAnonUser()
if err == nil { if err == nil {
session.Values["user_id"] = u.Uid session.Values["user_id"] = u.Uid