diff --git a/config/config.go b/config/config.go index 40d7cbb..493c6b6 100644 --- a/config/config.go +++ b/config/config.go @@ -47,9 +47,11 @@ func (*AmCLI) Version() string { // AmConfig holds the configuration of the application as read from YAML. type AmConfig struct { Site struct { - Title string `yaml:"title"` - TopRefresh int `yaml:"topRefresh"` - UserAgreement struct { + Title string `yaml:"title"` + TopRefresh int `yaml:"topRefresh"` + LoginCookieName string `yaml:"loginCookieName"` + LoginCookieAge int `yaml:"loginCookieAge"` + UserAgreement struct { Title string `yaml:"title"` Text string `yaml:"text"` } `yaml:"userAgreement"` @@ -106,6 +108,13 @@ func overlayString(loaded string, defaulted string) string { 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 { if loaded != 0 { return loaded @@ -122,6 +131,8 @@ func overlayInt(loaded int, defaulted int) int { func overlayConfig(dest *AmConfig, loaded *AmConfig, defaults *AmConfig) { dest.Site.Title = overlayString(loaded.Site.Title, defaults.Site.Title) 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.Text = overlayString(loaded.Site.UserAgreement.Text, defaults.Site.UserAgreement.Text) dest.Database.Driver = overlayString(loaded.Database.Driver, defaults.Database.Driver) diff --git a/config/default.yaml b/config/default.yaml index 1cf7e58..ea1dcb8 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -9,6 +9,8 @@ site: title: "Amsterdam Web Communities System" topRefresh: 300 + loginCookieName: AmsterdamAuth + loginCookieAge: 365 userAgreement: title: "Amsterdam User Agreement" text: > diff --git a/database/user.go b/database/user.go index 05af55b..0e36950 100644 --- a/database/user.go +++ b/database/user.go @@ -15,6 +15,8 @@ import ( "errors" "fmt" "hash/crc32" + "strconv" + "strings" "sync" "time" @@ -76,6 +78,9 @@ func (u *User) ContactInfo() (*ContactInfo, error) { * Standard Go error status. */ func (u *User) NewAuthToken() (string, error) { + if u.IsAnon { + return "", errors.New("cannot generate token for anonymous user") + } u.Mutex.Lock() defer u.Mutex.Unlock() newToken := util.GenerateRandomAuthString() @@ -217,7 +222,7 @@ func touchUser(user *User) { * The User pointer if authenticated, or nil if not. * 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) var ar *AuditRecord = nil defer func() { @@ -229,27 +234,100 @@ func AmAuthenticateUser(name string, password string, remote_ip string) (*User, user, err := AmGetUserByName(name) if err != nil { 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") } if user.IsAnon { 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") } if user.Lockout { 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") } h := hashPassword(password) if h != user.Passhash { 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") } log.Debug("...authenticated") 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 } diff --git a/login.go b/login.go index 9ab495c..e2e90d7 100644 --- a/login.go +++ b/login.go @@ -16,6 +16,7 @@ import ( "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/ui" + "github.com/labstack/gommon/log" ) /* LoginForm renders the Amsterdam login form. @@ -108,7 +109,14 @@ func Login(ctxt ui.AmContext) (string, any, error) { } ctxt.ReplaceUser(user) 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 return "redirect", target, nil @@ -135,7 +143,7 @@ func Logout(ctxt ui.AmContext) (string, any, error) { } if !ctxt.CurrentUser().IsAnon { - // TODO: erase login cookie + ctxt.ClearLoginCookie() ctxt.ClearSession() } return "redirect", target, nil diff --git a/ui/amcontext.go b/ui/amcontext.go index 048a0ee..39665b1 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -14,7 +14,9 @@ import ( "bytes" "net/http" "strconv" + "time" + "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" "github.com/CloudyKit/jet/v6" "github.com/gorilla/sessions" @@ -25,6 +27,7 @@ import ( // AmContext is the interface for Amsterdam's wrapper context that exposes the required functionality. type AmContext interface { + ClearLoginCookie() ClearSession() CurrentUser() *database.User CurrentUserId() int32 @@ -39,6 +42,7 @@ type AmContext interface { ReplaceUser(*database.User) SaveSession() error SubRender(string) ([]byte, error) + SetLoginCookie(string) SetOutputType(string) SetRC(int) GetScratch(string) any @@ -57,12 +61,22 @@ type amContext struct { 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. func (c *amContext) ClearSession() { for k := range c.session.Values { delete(c.session.Values, k) } - SetupAmSession(c.session) + setupAmSession(c.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 } +/* 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. func (c *amContext) SetOutputType(typ string) { c.outputType = typ @@ -253,7 +280,7 @@ func NewAmContext(ctxt echo.Context) (AmContext, error) { rc.session = sess sess.Options = defoptions if sess.IsNew { - SetupAmSession(sess) + setupAmSession(sess) } else { log.Debugf("took the not-new-session path") } diff --git a/ui/render_wrap.go b/ui/render_wrap.go index e8a4f94..14f78f8 100644 --- a/ui/render_wrap.go +++ b/ui/render_wrap.go @@ -14,8 +14,10 @@ import ( "fmt" "net/http" + "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" ) 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 { return func(ctxt echo.Context) error { + // Create the AmContext. amctxt, aerr := NewAmContext(ctxt) if aerr != nil { ctxt.Logger().Errorf("Session creation error: %v", aerr) return aerr } + + // Check IP banning. banmsg, banerr := database.AmTestIPBan(ctxt.RealIP()) if banerr != nil { 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 != "" { amctxt.VarMap().Set("amsterdam_pageTitle", "IP Address Banned") amctxt.VarMap().Set("message", banmsg) + amctxt.SetRC(http.StatusForbidden) 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) if err == nil { if err = amctxt.SaveSession(); err != nil { @@ -90,6 +120,7 @@ func AmWrap(myfunc func(AmContext) (string, any, error)) echo.HandlerFunc { } else { ctxt.Logger().Errorf("Page function error: %v", err) _, rc, _ = ErrorPage(amctxt, err) + amctxt.SetRC(http.StatusInternalServerError) newerr := sendPageData(ctxt, amctxt, "framed_template", rc) err = newerr } diff --git a/ui/session_mgr.go b/ui/session_mgr.go index fd98356..ba22941 100644 --- a/ui/session_mgr.go +++ b/ui/session_mgr.go @@ -26,8 +26,8 @@ func SetupSessionManager() { SessionStore = memstore.NewMemStore([]byte(config.GlobalConfig.Rendering.CookieKey)) } -// SetupAmSession sets up a newly created Amsterdam session. -func SetupAmSession(session *sessions.Session) { +// setupAmSession sets up a newly created Amsterdam session. +func setupAmSession(session *sessions.Session) { u, err := database.AmGetAnonUser() if err == nil { session.Values["user_id"] = u.Uid