فهرست منبع

Merge pull request #176 from qor5/optimize-login

optimize login
xuxinx 1 سال پیش
والد
کامیت
8f986dddfe
8فایلهای تغییر یافته به همراه402 افزوده شده و 221 حذف شده
  1. 186 147
      login/builder.go
  2. 5 10
      login/example/main.go
  3. 36 4
      login/flash.go
  4. 30 2
      login/messages.go
  5. 85 15
      login/middleware.go
  6. 21 0
      login/view_common.go
  7. 24 6
      login/view_helper.go
  8. 15 37
      login/views.go

+ 186 - 147
login/builder.go

@@ -5,8 +5,8 @@ import (
 	"errors"
 	"fmt"
 	"io/fs"
-	"log"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"time"
@@ -25,7 +25,7 @@ import (
 
 var (
 	ErrUserNotFound        = errors.New("user not found")
-	ErrUserPassChanged     = errors.New("password changed")
+	ErrPasswordChanged     = errors.New("password changed")
 	ErrWrongPassword       = errors.New("wrong password")
 	ErrUserLocked          = errors.New("user locked")
 	ErrUserGetLocked       = errors.New("user get locked")
@@ -38,8 +38,6 @@ var (
 type HomeURLFunc func(r *http.Request, user interface{}) string
 type HookFunc func(r *http.Request, user interface{}, extraVals ...interface{}) error
 
-type void struct{}
-
 type Provider struct {
 	Goth goth.Provider
 	Key  string
@@ -53,6 +51,10 @@ type CookieConfig struct {
 	SameSite http.SameSite
 }
 
+type TOTPConfig struct {
+	Issuer string
+}
+
 type RecaptchaConfig struct {
 	SiteKey   string
 	SecretKey string
@@ -67,17 +69,19 @@ type Builder struct {
 	// seconds
 	sessionMaxAge        int
 	cookieConfig         CookieConfig
+	totpEnabled          bool
+	totpConfig           TOTPConfig
 	recaptchaEnabled     bool
 	recaptchaConfig      RecaptchaConfig
 	autoExtendSession    bool
 	maxRetryCount        int
 	noForgetPasswordLink bool
+	i18nBuilder          *i18n.Builder
 
 	// Common URLs
 	homePageURLFunc HomeURLFunc
 	loginPageURL    string
 	LogoutURL       string
-	allowURLs       map[string]void
 
 	// TOTP URLs
 	validateTOTPURL     string
@@ -127,10 +131,6 @@ type Builder struct {
 	userPassEnabled      bool
 	oauthEnabled         bool
 	sessionSecureEnabled bool
-	totpEnabled          bool
-	totpIssuer           string
-
-	i18nBuilder *i18n.Builder
 }
 
 func New() *Builder {
@@ -171,12 +171,14 @@ func New() *Builder {
 		autoExtendSession: true,
 		maxRetryCount:     5,
 		totpEnabled:       true,
-		totpIssuer:        "qor5",
-		i18nBuilder:       i18n.New(),
+		totpConfig: TOTPConfig{
+			Issuer: "QOR5",
+		},
 	}
 
-	r.registerI18n()
-	r.initAllowURLs()
+	i18nB := i18n.New()
+	i18nB.SupportLanguages(language.English, language.SimplifiedChinese, language.Japanese)
+	r.I18n(i18nB)
 
 	vh := r.ViewHelper()
 	r.loginPageFunc = defaultLoginPage(vh)
@@ -190,25 +192,6 @@ func New() *Builder {
 	return r
 }
 
-func (b *Builder) initAllowURLs() {
-	b.allowURLs = map[string]void{
-		b.oauthBeginURL:                {},
-		b.oauthCallbackURL:             {},
-		b.oauthCallbackCompleteURL:     {},
-		b.passwordLoginURL:             {},
-		b.forgetPasswordPageURL:        {},
-		b.sendResetPasswordLinkURL:     {},
-		b.resetPasswordLinkSentPageURL: {},
-		b.resetPasswordURL:             {},
-		b.resetPasswordPageURL:         {},
-		b.validateTOTPURL:              {},
-	}
-}
-
-func (b *Builder) AllowURL(v string) {
-	b.allowURLs[v] = void{}
-}
-
 func (b *Builder) Secret(v string) (r *Builder) {
 	b.secret = v
 	return b
@@ -219,10 +202,20 @@ func (b *Builder) CookieConfig(v CookieConfig) (r *Builder) {
 	return b
 }
 
-// RecaptchaConfig should be set if you want to enable Google reCAPTCHA.
-func (b *Builder) RecaptchaConfig(v RecaptchaConfig) (r *Builder) {
-	b.recaptchaConfig = v
-	b.recaptchaEnabled = b.recaptchaConfig.SiteKey != "" && b.recaptchaConfig.SecretKey != ""
+// Google reCAPTCHA.
+func (b *Builder) Recaptcha(enable bool, config ...RecaptchaConfig) (r *Builder) {
+	b.recaptchaEnabled = enable
+	if len(config) > 0 {
+		b.recaptchaConfig = config[0]
+	}
+	if enable {
+		if b.recaptchaConfig.SiteKey == "" {
+			panic("SiteKey is empty")
+		}
+		if b.recaptchaConfig.SecretKey == "" {
+			panic("SecretKey is empty")
+		}
+	}
 	return b
 }
 
@@ -245,13 +238,43 @@ func (b *Builder) AuthCookieName(v string) (r *Builder) {
 	return b
 }
 
-func (b *Builder) LoginURL(v string) (r *Builder) {
+func (b *Builder) HomeURLFunc(v HomeURLFunc) (r *Builder) {
+	b.homePageURLFunc = v
+	return b
+}
+
+func (b *Builder) LoginPageURL(v string) (r *Builder) {
 	b.loginPageURL = v
 	return b
 }
 
-func (b *Builder) HomeURLFunc(v HomeURLFunc) (r *Builder) {
-	b.homePageURLFunc = v
+func (b *Builder) ResetPasswordPageURL(v string) (r *Builder) {
+	b.resetPasswordPageURL = v
+	return b
+}
+
+func (b *Builder) ChangePasswordPageURL(v string) (r *Builder) {
+	b.changePasswordPageURL = v
+	return b
+}
+
+func (b *Builder) ForgetPasswordPageURL(v string) (r *Builder) {
+	b.forgetPasswordPageURL = v
+	return b
+}
+
+func (b *Builder) ResetPasswordLinkSentPageURL(v string) (r *Builder) {
+	b.resetPasswordLinkSentPageURL = v
+	return b
+}
+
+func (b *Builder) TOTPSetupPageURL(v string) (r *Builder) {
+	b.totpSetupPageURL = v
+	return b
+}
+
+func (b *Builder) TOTPValidatePageURL(v string) (r *Builder) {
+	b.totpValidatePageURL = v
 	return b
 }
 
@@ -296,7 +319,7 @@ func (b *Builder) wrapHook(v HookFunc) HookFunc {
 	}
 
 	return func(r *http.Request, user interface{}, extraVals ...interface{}) error {
-		if GetCurrentUser(r) == nil {
+		if user != nil && GetCurrentUser(r) == nil {
 			r = r.WithContext(context.WithValue(r.Context(), UserKey, user))
 		}
 		return v(r, user, extraVals...)
@@ -315,6 +338,8 @@ func (b *Builder) AfterLogin(v HookFunc) (r *Builder) {
 	return b
 }
 
+// extra vals:
+// - login error
 func (b *Builder) AfterFailedToLogin(v HookFunc) (r *Builder) {
 	b.afterFailedToLoginHook = b.wrapHook(v)
 	return b
@@ -386,13 +411,16 @@ func (b *Builder) MaxRetryCount(v int) (r *Builder) {
 	return b
 }
 
-func (b *Builder) TOTPEnabled(v bool) (r *Builder) {
-	b.totpEnabled = v
-	return b
-}
-
-func (b *Builder) TOTPIssuer(v string) (r *Builder) {
-	b.totpIssuer = v
+func (b *Builder) TOTP(enable bool, config ...TOTPConfig) (r *Builder) {
+	b.totpEnabled = enable
+	if len(config) > 0 {
+		b.totpConfig = config[0]
+	}
+	if enable {
+		if b.totpConfig.Issuer == "" {
+			panic("Issuer is empty")
+		}
+	}
 	return b
 }
 
@@ -407,8 +435,10 @@ func (b *Builder) DB(v *gorm.DB) (r *Builder) {
 }
 
 func (b *Builder) I18n(v *i18n.Builder) (r *Builder) {
+	v.RegisterForModule(language.English, I18nLoginKey, Messages_en_US).
+		RegisterForModule(language.SimplifiedChinese, I18nLoginKey, Messages_zh_CN).
+		RegisterForModule(language.Japanese, I18nLoginKey, Messages_ja_JP)
 	b.i18nBuilder = v
-	b.registerI18n()
 	return b
 }
 
@@ -422,12 +452,6 @@ func (b *Builder) ViewHelper() *ViewHelper {
 	}
 }
 
-func (b *Builder) registerI18n() {
-	b.i18nBuilder.RegisterForModule(language.English, I18nLoginKey, Messages_en_US).
-		RegisterForModule(language.SimplifiedChinese, I18nLoginKey, Messages_zh_CN).
-		RegisterForModule(language.Japanese, I18nLoginKey, Messages_ja_JP)
-}
-
 func (b *Builder) UserModel(m interface{}) (r *Builder) {
 	b.userModel = m
 	b.tUser = underlyingReflectType(reflect.TypeOf(m))
@@ -483,25 +507,28 @@ window.location.href="%s";
 func (b *Builder) completeUserAuthCallbackComplete(w http.ResponseWriter, r *http.Request) {
 	var err error
 	var user interface{}
+	failRedirectURL := b.LogoutURL
 	defer func() {
-		if b.afterFailedToLoginHook != nil && err != nil && user != nil {
-			b.afterFailedToLoginHook(r, user)
+		if err != nil {
+			if b.afterFailedToLoginHook != nil {
+				if herr := b.afterFailedToLoginHook(r, user, err); herr != nil {
+					setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
+				}
+			}
+			http.Redirect(w, r, failRedirectURL, http.StatusFound)
 		}
 	}()
 
 	var ouser goth.User
 	ouser, err = gothic.CompleteUserAuth(w, r)
 	if err != nil {
-		log.Println("completeUserAuthWithSetCookie", err)
 		setFailCodeFlash(w, FailCodeCompleteUserAuthFailed)
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
 	if b.afterOAuthCompleteHook != nil {
-		if herr := b.afterOAuthCompleteHook(r, ouser); herr != nil {
-			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.LogoutURL, http.StatusFound)
+		if err = b.afterOAuthCompleteHook(r, ouser); err != nil {
+			setNoticeOrFailCodeFlash(w, err, FailCodeSystemError)
 			return
 		}
 	}
@@ -513,7 +540,6 @@ func (b *Builder) completeUserAuthCallbackComplete(w http.ResponseWriter, r *htt
 		if err != nil {
 			if err != gorm.ErrRecordNotFound {
 				setFailCodeFlash(w, FailCodeSystemError)
-				http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 				return
 			}
 			// TODO: maybe the identifier of some providers is not email
@@ -525,13 +551,11 @@ func (b *Builder) completeUserAuthCallbackComplete(w http.ResponseWriter, r *htt
 				} else {
 					setFailCodeFlash(w, FailCodeSystemError)
 				}
-				http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 				return
 			}
 			err = user.(OAuthUser).InitOAuthUserID(b.db, b.newUserObject(), ouser.Provider, identifier, ouser.UserID)
 			if err != nil {
 				setFailCodeFlash(w, FailCodeSystemError)
-				http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 				return
 			}
 		}
@@ -552,16 +576,14 @@ func (b *Builder) completeUserAuthCallbackComplete(w http.ResponseWriter, r *htt
 
 	if b.afterLoginHook != nil {
 		setCookieForRequest(r, &http.Cookie{Name: b.authCookieName, Value: b.mustGetSessionToken(claims)})
-		if herr := b.afterLoginHook(r, user); herr != nil {
-			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.loginPageURL, http.StatusFound)
+		if err = b.afterLoginHook(r, user); err != nil {
+			setNoticeOrFailCodeFlash(w, err, FailCodeSystemError)
 			return
 		}
 	}
 
-	if err := b.setSecureCookiesByClaims(w, user, claims); err != nil {
+	if err = b.setSecureCookiesByClaims(w, user, claims); err != nil {
 		setFailCodeFlash(w, FailCodeSystemError)
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
@@ -631,9 +653,15 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 
 	var err error
 	var user interface{}
+	failRedirectURL := b.LogoutURL
 	defer func() {
-		if b.afterFailedToLoginHook != nil && err != nil && user != nil {
-			b.afterFailedToLoginHook(r, user)
+		if err != nil {
+			if b.afterFailedToLoginHook != nil {
+				if herr := b.afterFailedToLoginHook(r, user, err); herr != nil {
+					setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
+				}
+			}
+			http.Redirect(w, r, failRedirectURL, http.StatusFound)
 		}
 	}()
 
@@ -642,9 +670,8 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 	user, err = b.authUserPass(account, password)
 	if err != nil {
 		if err == ErrUserGetLocked && b.afterUserLockedHook != nil {
-			if herr := b.afterUserLockedHook(r, user); herr != nil {
-				setFailCodeFlash(w, FailCodeSystemError)
-				http.Redirect(w, r, b.loginPageURL, http.StatusFound)
+			if err = b.afterUserLockedHook(r, user); err != nil {
+				setNoticeOrFailCodeFlash(w, err, FailCodeSystemError)
 				return
 			}
 		}
@@ -661,7 +688,6 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 			Account:  account,
 			Password: password,
 		})
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
@@ -676,9 +702,8 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 	if !b.totpEnabled {
 		if b.afterLoginHook != nil {
 			setCookieForRequest(r, &http.Cookie{Name: b.authCookieName, Value: b.mustGetSessionToken(claims)})
-			if herr := b.afterLoginHook(r, user); herr != nil {
-				setFailCodeFlash(w, FailCodeSystemError)
-				http.Redirect(w, r, b.loginPageURL, http.StatusFound)
+			if err = b.afterLoginHook(r, user); err != nil {
+				setNoticeOrFailCodeFlash(w, err, FailCodeSystemError)
 				return
 			}
 		}
@@ -686,7 +711,6 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 
 	if err = b.setSecureCookiesByClaims(w, user, claims); err != nil {
 		setFailCodeFlash(w, FailCodeSystemError)
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
@@ -699,18 +723,16 @@ func (b *Builder) userpassLogin(w http.ResponseWriter, r *http.Request) {
 		var key *otp.Key
 		if key, err = totp.Generate(
 			totp.GenerateOpts{
-				Issuer:      b.totpIssuer,
+				Issuer:      b.totpConfig.Issuer,
 				AccountName: u.GetAccountName(),
 			},
 		); err != nil {
 			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 			return
 		}
 
 		if err = u.SetTOTPSecret(b.db, b.newUserObject(), key.Secret()); err != nil {
 			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 			return
 		}
 
@@ -793,15 +815,36 @@ func (b *Builder) setContinueURL(w http.ResponseWriter, r *http.Request) {
 	if strings.Contains(continueURL, "?__execute_event__=") {
 		continueURL = r.Referer()
 	}
-	if !strings.HasPrefix(continueURL, "/auth/") {
-		http.SetCookie(w, &http.Cookie{
-			Name:     b.continueUrlCookieName,
-			Value:    continueURL,
-			Path:     b.cookieConfig.Path,
-			Domain:   b.cookieConfig.Domain,
-			HttpOnly: true,
-		})
+	ignore := false
+	{
+		ignoreURLs := map[string]struct{}{
+			b.loginPageURL:                 {},
+			b.resetPasswordPageURL:         {},
+			b.forgetPasswordPageURL:        {},
+			b.resetPasswordLinkSentPageURL: {},
+			b.totpSetupPageURL:             {},
+			b.totpValidatePageURL:          {},
+			b.LogoutURL:                    {},
+		}
+		u, err := url.Parse(continueURL)
+		if err != nil {
+			ignore = true
+		} else {
+			if _, ok := ignoreURLs[u.Path]; ok {
+				ignore = true
+			}
+		}
 	}
+	if ignore {
+		return
+	}
+	http.SetCookie(w, &http.Cookie{
+		Name:     b.continueUrlCookieName,
+		Value:    continueURL,
+		Path:     b.cookieConfig.Path,
+		Domain:   b.cookieConfig.Domain,
+		HttpOnly: true,
+	})
 }
 
 func (b *Builder) getContinueURL(w http.ResponseWriter, r *http.Request) string {
@@ -888,7 +931,7 @@ func (b *Builder) logout(w http.ResponseWriter, r *http.Request) {
 		user := GetCurrentUser(r)
 		if user != nil {
 			if herr := b.afterLogoutHook(r, user); herr != nil {
-				setFailCodeFlash(w, FailCodeSystemError)
+				setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
 				http.Redirect(w, r, b.loginPageURL, http.StatusFound)
 				return
 			}
@@ -974,7 +1017,7 @@ func (b *Builder) sendResetPasswordLink(w http.ResponseWriter, r *http.Request)
 
 		if err = b.consumeTOTPCode(r, u.(UserPasser), passcode); err != nil {
 			fc := b.getFailCodeFromTOTPCodeConsumeError(err)
-			setFailCodeFlash(w, fc)
+			setNoticeOrFailCodeFlash(w, err, fc)
 			setWrongForgetPasswordInputFlash(w, WrongForgetPasswordInputFlash{
 				Account: account,
 				TOTP:    passcode,
@@ -1004,7 +1047,7 @@ func (b *Builder) sendResetPasswordLink(w http.ResponseWriter, r *http.Request)
 	}
 	if b.afterConfirmSendResetPasswordLinkHook != nil {
 		if herr := b.afterConfirmSendResetPasswordLinkHook(r, u, link); herr != nil {
-			setFailCodeFlash(w, FailCodeSystemError)
+			setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
 			http.Redirect(w, r, failRedirectURL, http.StatusFound)
 			return
 		}
@@ -1082,13 +1125,7 @@ func (b *Builder) doResetPassword(w http.ResponseWriter, r *http.Request) {
 
 	if b.beforeSetPasswordHook != nil {
 		if herr := b.beforeSetPasswordHook(r, u, password); herr != nil {
-			verr, ok := herr.(*ValidationError)
-			if !ok {
-				setFailCodeFlash(w, FailCodeSystemError)
-				http.Redirect(w, r, failRedirectURL, http.StatusFound)
-				return
-			}
-			setCustomErrorMessageFlash(w, verr.Msg)
+			setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
 			setWrongResetPasswordInputFlash(w, WrongResetPasswordInputFlash{
 				Password:        password,
 				ConfirmPassword: confirmPassword,
@@ -1138,7 +1175,7 @@ func (b *Builder) doResetPassword(w http.ResponseWriter, r *http.Request) {
 
 	if b.afterResetPasswordHook != nil {
 		if herr := b.afterResetPasswordHook(r, u); herr != nil {
-			setFailCodeFlash(w, FailCodeSystemError)
+			setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
 			http.Redirect(w, r, failRedirectURL, http.StatusFound)
 			return
 		}
@@ -1149,20 +1186,12 @@ func (b *Builder) doResetPassword(w http.ResponseWriter, r *http.Request) {
 	return
 }
 
-type ValidationError struct {
-	Msg string
-}
-
-func (e *ValidationError) Error() string {
-	return e.Msg
-}
-
-// validationError
-// errWrongPassword
-// errEmptyPassword
-// errPasswordNotMatch
-// errWrongTOTPCode
-// errTOTPCodeHasBeenUsed
+// NoticeError
+// ErrWrongPassword
+// ErrEmptyPassword
+// ErrPasswordNotMatch
+// ErrWrongTOTPCode
+// ErrTOTPCodeHasBeenUsed
 func (b *Builder) ChangePassword(
 	r *http.Request,
 	oldPassword string,
@@ -1224,8 +1253,8 @@ func (b *Builder) doFormChangePassword(w http.ResponseWriter, r *http.Request) {
 
 	err := b.ChangePassword(r, oldPassword, password, confirmPassword, otp)
 	if err != nil {
-		if ve, ok := err.(*ValidationError); ok {
-			setCustomErrorMessageFlash(w, ve.Msg)
+		if ne, ok := err.(*NoticeError); ok {
+			setNoticeFlash(w, ne)
 		} else {
 			fc := FailCodeSystemError
 			switch err {
@@ -1265,16 +1294,21 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 
 	var err error
 	var user interface{}
+	failRedirectURL := b.LogoutURL
 	defer func() {
-		if b.afterFailedToLoginHook != nil && err != nil && user != nil {
-			b.afterFailedToLoginHook(r, user)
+		if err != nil {
+			if b.afterFailedToLoginHook != nil {
+				if herr := b.afterFailedToLoginHook(r, user, err); herr != nil {
+					setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
+				}
+			}
+			http.Redirect(w, r, failRedirectURL, http.StatusFound)
 		}
 	}()
 
 	var claims *UserClaims
 	claims, err = parseUserClaimsFromCookie(r, b.authCookieName, b.secret)
 	if err != nil {
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
@@ -1285,7 +1319,6 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 		} else {
 			setFailCodeFlash(w, FailCodeSystemError)
 		}
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 	u := user.(UserPasser)
@@ -1293,21 +1326,19 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 	otp := r.FormValue("otp")
 	isTOTPSetup := u.GetIsTOTPSetup()
 
-	if err := b.consumeTOTPCode(r, u, otp); err != nil {
+	if err = b.consumeTOTPCode(r, u, otp); err != nil {
 		fc := b.getFailCodeFromTOTPCodeConsumeError(err)
 		setFailCodeFlash(w, fc)
-		redirectURL := b.totpValidatePageURL
+		failRedirectURL = b.totpValidatePageURL
 		if !isTOTPSetup {
-			redirectURL = b.totpSetupPageURL
+			failRedirectURL = b.totpSetupPageURL
 		}
-		http.Redirect(w, r, redirectURL, http.StatusFound)
 		return
 	}
 
 	if !isTOTPSetup {
 		if err = u.SetIsTOTPSetup(b.db, b.newUserObject(), true); err != nil {
 			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 			return
 		}
 	}
@@ -1315,9 +1346,8 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 	claims.TOTPValidated = true
 	if b.afterLoginHook != nil {
 		setCookieForRequest(r, &http.Cookie{Name: b.authCookieName, Value: b.mustGetSessionToken(*claims)})
-		if herr := b.afterLoginHook(r, user); herr != nil {
-			setFailCodeFlash(w, FailCodeSystemError)
-			http.Redirect(w, r, b.loginPageURL, http.StatusFound)
+		if err = b.afterLoginHook(r, user); err != nil {
+			setNoticeOrFailCodeFlash(w, err, FailCodeSystemError)
 			return
 		}
 	}
@@ -1325,7 +1355,6 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 	err = b.setSecureCookiesByClaims(w, user, *claims)
 	if err != nil {
 		setFailCodeFlash(w, FailCodeSystemError)
-		http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 		return
 	}
 
@@ -1337,6 +1366,33 @@ func (b *Builder) totpDo(w http.ResponseWriter, r *http.Request) {
 }
 
 func (b *Builder) Mount(mux *http.ServeMux) {
+	b.MountAPI(mux)
+
+	// pages
+	wb := web.New()
+	mux.Handle(b.loginPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.loginPageFunc)))
+	if b.userPassEnabled {
+		mux.Handle(b.resetPasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.resetPasswordPageFunc)))
+		mux.Handle(b.changePasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.changePasswordPageFunc)))
+		if !b.noForgetPasswordLink {
+			mux.Handle(b.forgetPasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.forgetPasswordPageFunc)))
+			mux.Handle(b.resetPasswordLinkSentPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.resetPasswordLinkSentPageFunc)))
+		}
+		if b.totpEnabled {
+			mux.Handle(b.totpSetupPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.totpSetupPageFunc)))
+			mux.Handle(b.totpValidatePageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.totpValidatePageFunc)))
+		}
+	}
+
+	// assets
+	assetsSubFS, err := fs.Sub(assetsFS, "assets")
+	if err != nil {
+		panic(err)
+	}
+	mux.Handle(assetsPathPrefix, http.StripPrefix(assetsPathPrefix, http.FileServer(http.FS(assetsSubFS))))
+}
+
+func (b *Builder) MountAPI(mux *http.ServeMux) {
 	if len(b.secret) == 0 {
 		panic("secret is empty")
 	}
@@ -1346,26 +1402,16 @@ func (b *Builder) Mount(mux *http.ServeMux) {
 		}
 	}
 
-	wb := web.New()
-
 	mux.HandleFunc(b.LogoutURL, b.logout)
-	mux.Handle(b.loginPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.loginPageFunc)))
-
 	if b.userPassEnabled {
 		mux.HandleFunc(b.passwordLoginURL, b.userpassLogin)
 		mux.HandleFunc(b.resetPasswordURL, b.doResetPassword)
 		mux.HandleFunc(b.changePasswordURL, b.doFormChangePassword)
-		mux.Handle(b.resetPasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.resetPasswordPageFunc)))
-		mux.Handle(b.changePasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.changePasswordPageFunc)))
 		if !b.noForgetPasswordLink {
 			mux.HandleFunc(b.sendResetPasswordLinkURL, b.sendResetPasswordLink)
-			mux.Handle(b.forgetPasswordPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.forgetPasswordPageFunc)))
-			mux.Handle(b.resetPasswordLinkSentPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.resetPasswordLinkSentPageFunc)))
 		}
 		if b.totpEnabled {
 			mux.HandleFunc(b.validateTOTPURL, b.totpDo)
-			mux.Handle(b.totpSetupPageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.totpSetupPageFunc)))
-			mux.Handle(b.totpValidatePageURL, b.i18nBuilder.EnsureLanguage(wb.Page(b.totpValidatePageFunc)))
 		}
 	}
 	if b.oauthEnabled {
@@ -1373,11 +1419,4 @@ func (b *Builder) Mount(mux *http.ServeMux) {
 		mux.HandleFunc(b.oauthCallbackURL, b.completeUserAuthCallback)
 		mux.HandleFunc(b.oauthCallbackCompleteURL, b.completeUserAuthCallbackComplete)
 	}
-
-	// assets
-	assetsSubFS, err := fs.Sub(assetsFS, "assets")
-	if err != nil {
-		panic(err)
-	}
-	mux.Handle(assetsPathPrefix, http.StripPrefix(assetsPathPrefix, http.FileServer(http.FS(assetsSubFS))))
 }

+ 5 - 10
login/example/main.go

@@ -8,11 +8,9 @@ import (
 
 	"github.com/markbates/goth/providers/google"
 	"github.com/markbates/goth/providers/twitter"
-	"github.com/qor5/x/i18n"
 	"github.com/qor5/x/login"
 	. "github.com/theplant/htmlgo"
 	"github.com/theplant/testingutils"
-	"golang.org/x/text/language"
 	"gorm.io/driver/sqlite"
 	"gorm.io/gorm"
 )
@@ -70,8 +68,8 @@ func main() {
 		BeforeSetPassword(func(r *http.Request, user interface{}, extraVals ...interface{}) error {
 			password := extraVals[0].(string)
 			if len(password) <= 2 {
-				return &login.ValidationError{
-					Msg: "password length cannot be less than 2",
+				return &login.NoticeError{
+					Message: "password length cannot be less than 2",
 				}
 			}
 			return nil
@@ -82,11 +80,8 @@ func main() {
 			testingutils.PrintlnJson(link)
 			fmt.Println("#########################################end")
 			return nil
-		})
-
-	i18nB := i18n.New()
-	i18nB.SupportLanguages(language.English, language.SimplifiedChinese)
-	b.I18n(i18nB)
+		}).
+		TOTP(false)
 
 	h := http.NewServeMux()
 	b.Mount(h)
@@ -95,7 +90,7 @@ func main() {
 	}))
 
 	mux := http.NewServeMux()
-	mux.Handle("/", login.Authenticate(b)(h))
+	mux.Handle("/", b.Middleware()(h))
 
 	log.Println("serving at http://localhost:9500")
 	log.Fatal(http.ListenAndServe(":9500", mux))

+ 36 - 4
login/flash.go

@@ -7,6 +7,23 @@ import (
 	"net/http"
 )
 
+type NoticeLevel int
+
+const (
+	NoticeLevel_Info NoticeLevel = iota
+	NoticeLevel_Warn
+	NoticeLevel_Error
+)
+
+type NoticeError struct {
+	Level   NoticeLevel
+	Message string
+}
+
+func (e *NoticeError) Error() string {
+	return e.Message
+}
+
 type FailCode int
 
 const (
@@ -70,17 +87,32 @@ func setInfoCodeFlash(w http.ResponseWriter, c InfoCode) {
 	})
 }
 
-const customErrorMessageFlashCookieName = "qor5_cem_flash"
+const noticeFlashCookieName = "qor5_notice_flash"
 
-func setCustomErrorMessageFlash(w http.ResponseWriter, f string) {
+func setNoticeFlash(w http.ResponseWriter, ne *NoticeError) {
+	if ne == nil {
+		return
+	}
 	http.SetCookie(w, &http.Cookie{
-		Name:     customErrorMessageFlashCookieName,
-		Value:    f,
+		Name:     noticeFlashCookieName,
+		Value:    fmt.Sprintf("%d#%s", ne.Level, ne.Message),
 		Path:     "/",
 		HttpOnly: true,
 	})
 }
 
+func setNoticeOrFailCodeFlash(w http.ResponseWriter, err error, c FailCode) {
+	if err == nil {
+		return
+	}
+	ne, ok := err.(*NoticeError)
+	if ok {
+		setNoticeFlash(w, ne)
+		return
+	}
+	setFailCodeFlash(w, c)
+}
+
 const wrongLoginInputFlashCookieName = "qor5_wli_flash"
 
 type WrongLoginInputFlash struct {

+ 30 - 2
login/messages.go

@@ -9,6 +9,7 @@ type Messages struct {
 	Confirm string
 	Verify  string
 	// login page
+	LoginPageTitle      string
 	AccountLabel        string
 	AccountPlaceholder  string
 	PasswordLabel       string
@@ -16,6 +17,7 @@ type Messages struct {
 	SignInBtn           string
 	ForgetPasswordLink  string
 	// forget password page
+	ForgetPasswordPageTitle        string
 	ForgotMyPasswordTitle          string
 	ForgetPasswordEmailLabel       string
 	ForgetPasswordEmailPlaceholder string
@@ -23,15 +25,18 @@ type Messages struct {
 	ResendResetPasswordEmailBtn    string
 	SendEmailTooFrequentlyNotice   string
 	// reset password link sent page
-	ResetPasswordLinkWasSentTo  string
-	ResetPasswordLinkSentPrompt string
+	ResetPasswordLinkSentPageTitle string
+	ResetPasswordLinkWasSentTo     string
+	ResetPasswordLinkSentPrompt    string
 	// reset password page
+	ResetPasswordPageTitle          string
 	ResetYourPasswordTitle          string
 	ResetPasswordLabel              string
 	ResetPasswordPlaceholder        string
 	ResetPasswordConfirmLabel       string
 	ResetPasswordConfirmPlaceholder string
 	// change password page
+	ChangePasswordPageTitle             string
 	ChangePasswordTitle                 string
 	ChangePasswordOldLabel              string
 	ChangePasswordOldPlaceholder        string
@@ -40,12 +45,14 @@ type Messages struct {
 	ChangePasswordNewConfirmLabel       string
 	ChangePasswordNewConfirmPlaceholder string
 	// TOTP setup page
+	TOTPSetupPageTitle       string
 	TOTPSetupTitle           string
 	TOTPSetupScanPrompt      string
 	TOTPSetupSecretPrompt    string
 	TOTPSetupEnterCodePrompt string
 	TOTPSetupCodePlaceholder string
 	// TOTP validate page
+	TOTPValidatePageTitle       string
 	TOTPValidateTitle           string
 	TOTPValidateEnterCodePrompt string
 	TOTPValidateCodeLabel       string
@@ -75,25 +82,30 @@ type Messages struct {
 var Messages_en_US = &Messages{
 	Confirm:                             "Confirm",
 	Verify:                              "Verify",
+	LoginPageTitle:                      "Sign In",
 	AccountLabel:                        "Email",
 	AccountPlaceholder:                  "Email",
 	PasswordLabel:                       "Password",
 	PasswordPlaceholder:                 "Password",
 	SignInBtn:                           "Sign In",
 	ForgetPasswordLink:                  "Forget your password?",
+	ForgetPasswordPageTitle:             "Forget Your Password?",
 	ForgotMyPasswordTitle:               "I forgot my password",
 	ForgetPasswordEmailLabel:            "Enter your email",
 	ForgetPasswordEmailPlaceholder:      "Email",
 	SendResetPasswordEmailBtn:           "Send reset password email",
 	ResendResetPasswordEmailBtn:         "Resend reset password email",
 	SendEmailTooFrequentlyNotice:        "Sending emails too frequently, please try again later",
+	ResetPasswordLinkSentPageTitle:      "Forget Your Password?",
 	ResetPasswordLinkWasSentTo:          "A reset password link was sent to",
 	ResetPasswordLinkSentPrompt:         "You can close this page and reset your password from this link.",
+	ResetPasswordPageTitle:              "Reset Password",
 	ResetYourPasswordTitle:              "Reset your password",
 	ResetPasswordLabel:                  "Change your password",
 	ResetPasswordPlaceholder:            "New password",
 	ResetPasswordConfirmLabel:           "Re-enter new password",
 	ResetPasswordConfirmPlaceholder:     "Confirm new password",
+	ChangePasswordPageTitle:             "Change Password",
 	ChangePasswordTitle:                 "Change your password",
 	ChangePasswordOldLabel:              "Old password",
 	ChangePasswordOldPlaceholder:        "Old Password",
@@ -101,11 +113,13 @@ var Messages_en_US = &Messages{
 	ChangePasswordNewPlaceholder:        "New Password",
 	ChangePasswordNewConfirmLabel:       "Re-enter new password",
 	ChangePasswordNewConfirmPlaceholder: "New Password",
+	TOTPSetupPageTitle:                  "TOTP Setup",
 	TOTPSetupTitle:                      "Two Factor Authentication",
 	TOTPSetupScanPrompt:                 "Scan this QR code with Google Authenticator (or similar) app",
 	TOTPSetupSecretPrompt:               "Or manually enter the following code into your preferred authenticator app",
 	TOTPSetupEnterCodePrompt:            "Then enter the provided one-time code below",
 	TOTPSetupCodePlaceholder:            "Passcode",
+	TOTPValidatePageTitle:               "TOTP Validate",
 	TOTPValidateTitle:                   "Two Factor Authentication",
 	TOTPValidateEnterCodePrompt:         "Enter the provided one-time code below",
 	TOTPValidateCodeLabel:               "Authenticator passcode",
@@ -132,25 +146,30 @@ var Messages_en_US = &Messages{
 var Messages_zh_CN = &Messages{
 	Confirm:                             "确认",
 	Verify:                              "验证",
+	LoginPageTitle:                      "登录",
 	AccountLabel:                        "邮箱",
 	AccountPlaceholder:                  "邮箱",
 	PasswordLabel:                       "密码",
 	PasswordPlaceholder:                 "密码",
 	SignInBtn:                           "登录",
 	ForgetPasswordLink:                  "忘记密码?",
+	ForgetPasswordPageTitle:             "忘记密码?",
 	ForgotMyPasswordTitle:               "我忘记密码了",
 	ForgetPasswordEmailLabel:            "输入您的电子邮箱",
 	ForgetPasswordEmailPlaceholder:      "电子邮箱",
 	SendResetPasswordEmailBtn:           "发送重置密码电子邮件",
 	ResendResetPasswordEmailBtn:         "重新发送重置密码电子邮件",
 	SendEmailTooFrequentlyNotice:        "邮件发送过于频繁,请稍后再试",
+	ResetPasswordLinkSentPageTitle:      "忘记密码?",
 	ResetPasswordLinkWasSentTo:          "已将重置密码链接发送到",
 	ResetPasswordLinkSentPrompt:         "您可以关闭此页面并从此链接重置密码。",
+	ResetPasswordPageTitle:              "重置密码",
 	ResetYourPasswordTitle:              "重置您的密码",
 	ResetPasswordLabel:                  "改变您的密码",
 	ResetPasswordPlaceholder:            "新密码",
 	ResetPasswordConfirmLabel:           "再次输入新密码",
 	ResetPasswordConfirmPlaceholder:     "新密码",
+	ChangePasswordPageTitle:             "修改密码",
 	ChangePasswordTitle:                 "修改您的密码",
 	ChangePasswordOldLabel:              "旧密码",
 	ChangePasswordOldPlaceholder:        "旧密码",
@@ -158,11 +177,13 @@ var Messages_zh_CN = &Messages{
 	ChangePasswordNewPlaceholder:        "新密码",
 	ChangePasswordNewConfirmLabel:       "再次输入新密码",
 	ChangePasswordNewConfirmPlaceholder: "新密码",
+	TOTPSetupPageTitle:                  "双重认证",
 	TOTPSetupTitle:                      "双重认证",
 	TOTPSetupScanPrompt:                 "使用Google Authenticator(或类似)应用程序扫描此二维码",
 	TOTPSetupSecretPrompt:               "或者将以下代码手动输入到您首选的验证器应用程序中",
 	TOTPSetupEnterCodePrompt:            "然后在下面输入提供的一次性代码",
 	TOTPSetupCodePlaceholder:            "passcode",
+	TOTPValidatePageTitle:               "双重认证",
 	TOTPValidateTitle:                   "双重认证",
 	TOTPValidateEnterCodePrompt:         "在下面输入提供的一次性代码",
 	TOTPValidateCodeLabel:               "Authenticator验证码",
@@ -189,25 +210,30 @@ var Messages_zh_CN = &Messages{
 var Messages_ja_JP = &Messages{
 	Confirm:                             "確認する",
 	Verify:                              "検証",
+	LoginPageTitle:                      "ログイン",
 	AccountLabel:                        "メールアドレス",
 	AccountPlaceholder:                  "メールアドレス",
 	PasswordLabel:                       "パスワード",
 	PasswordPlaceholder:                 "パスワード",
 	SignInBtn:                           "ログイン",
 	ForgetPasswordLink:                  "パスワードをお忘れですか?",
+	ForgetPasswordPageTitle:             "パスワードをお忘れですか?",
 	ForgotMyPasswordTitle:               "パスワードを忘れました",
 	ForgetPasswordEmailLabel:            "メールアドレスを入力してください",
 	ForgetPasswordEmailPlaceholder:      "メールアドレス",
 	SendResetPasswordEmailBtn:           "パスワードリセット用メールが送信されました",
 	ResendResetPasswordEmailBtn:         "パスワードリセット用メールを再送する",
 	SendEmailTooFrequentlyNotice:        "メール送信回数が上限を超えています。しばらく経ってから再度お試しください",
+	ResetPasswordLinkSentPageTitle:      "パスワードをお忘れですか?",
 	ResetPasswordLinkWasSentTo:          "パスワードリセット用リンクが送信されました",
 	ResetPasswordLinkSentPrompt:         "このリンクからパスワードリセット手続きを行い、終了後はページを閉じてください",
+	ResetPasswordPageTitle:              "パスワードをリセットしてください",
 	ResetYourPasswordTitle:              "パスワードをリセットしてください",
 	ResetPasswordLabel:                  "パスワードを変更する",
 	ResetPasswordPlaceholder:            "新しいパスワード",
 	ResetPasswordConfirmLabel:           "新しいパスワードを再入力",
 	ResetPasswordConfirmPlaceholder:     "新しいパスワードを確認する",
+	ChangePasswordPageTitle:             "パスワードを変更する",
 	ChangePasswordTitle:                 "パスワードを変更する",
 	ChangePasswordOldLabel:              "古いパスワード",
 	ChangePasswordOldPlaceholder:        "古いパスワード",
@@ -215,11 +241,13 @@ var Messages_ja_JP = &Messages{
 	ChangePasswordNewPlaceholder:        "新しいパスワード",
 	ChangePasswordNewConfirmLabel:       "新しいパスワードを再入力する",
 	ChangePasswordNewConfirmPlaceholder: "新しいパスワード",
+	TOTPSetupPageTitle:                  "二段階認証",
 	TOTPSetupTitle:                      "二段階認証",
 	TOTPSetupScanPrompt:                 "Google認証アプリ(または同等アプリ)を利用してこのQRコードをスキャンしてください",
 	TOTPSetupSecretPrompt:               "または、お好きな認証アプリを利用して、以下のコードを入力してください",
 	TOTPSetupEnterCodePrompt:            "以下のワンタイムコードを入力してください",
 	TOTPSetupCodePlaceholder:            "パスコード",
+	TOTPValidatePageTitle:               "二段階認証",
 	TOTPValidateTitle:                   "二段階認証",
 	TOTPValidateEnterCodePrompt:         "提供されたワンタイムコードを以下に入力してください",
 	TOTPValidateCodeLabel:               "認証パスコード",

+ 85 - 15
login/middlewares.go → login/middleware.go

@@ -2,9 +2,9 @@ package login
 
 import (
 	"context"
-	"log"
 	"net/http"
 	"regexp"
+	"strconv"
 	"strings"
 	"time"
 )
@@ -16,17 +16,54 @@ const (
 	loginWIPKey
 )
 
-var staticFileRe = regexp.MustCompile(`\.(css|js|gif|jpg|jpeg|png|ico|svg|ttf|eot|woff|woff2)$`)
+type MiddlewareConfig interface {
+	middlewareConfig()
+}
+
+// LoginNotRequired executes the next handler regardless of whether the user is logged in or not
+type LoginNotRequired struct{}
+
+func (*LoginNotRequired) middlewareConfig() {}
+
+// DisableAutoRedirectToHomePage makes it possible to visit login page when user is logged in
+type DisableAutoRedirectToHomePage struct{}
+
+func (*DisableAutoRedirectToHomePage) middlewareConfig() {}
+
+func (b *Builder) Middleware(cfgs ...MiddlewareConfig) func(next http.Handler) http.Handler {
+	mustLogin := true
+	autoRedirectToHomePage := true
+	for _, cfg := range cfgs {
+		switch cfg.(type) {
+		case *LoginNotRequired:
+			mustLogin = false
+		case *DisableAutoRedirectToHomePage:
+			autoRedirectToHomePage = false
+		}
+	}
+
+	whiteList := map[string]struct{}{
+		b.oauthBeginURL:                {},
+		b.oauthCallbackURL:             {},
+		b.oauthCallbackCompleteURL:     {},
+		b.passwordLoginURL:             {},
+		b.forgetPasswordPageURL:        {},
+		b.sendResetPasswordLinkURL:     {},
+		b.resetPasswordLinkSentPageURL: {},
+		b.resetPasswordURL:             {},
+		b.resetPasswordPageURL:         {},
+		b.validateTOTPURL:              {},
+	}
+
+	staticFileRe := regexp.MustCompile(`\.(css|js|gif|jpg|jpeg|png|ico|svg|ttf|eot|woff|woff2)$`)
 
-func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			if staticFileRe.MatchString(strings.ToLower(r.URL.Path)) {
 				next.ServeHTTP(w, r)
 				return
 			}
-
-			if _, ok := b.allowURLs[r.URL.Path]; ok {
+			if _, ok := whiteList[r.URL.Path]; ok {
 				next.ServeHTTP(w, r)
 				return
 			}
@@ -35,8 +72,13 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 
 			claims, err := parseUserClaimsFromCookie(r, b.authCookieName, b.secret)
 			if err != nil {
-				log.Println(err)
-				b.setContinueURL(w, r)
+				if !mustLogin {
+					next.ServeHTTP(w, r)
+					return
+				}
+				if r.Method == http.MethodGet {
+					b.setContinueURL(w, r)
+				}
 				if path == b.loginPageURL {
 					next.ServeHTTP(w, r)
 				} else {
@@ -53,7 +95,7 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 				if err == nil {
 					if claims.Provider == "" {
 						if user.(UserPasser).GetPasswordUpdatedAt() != claims.PassUpdatedAt {
-							err = ErrUserPassChanged
+							err = ErrPasswordChanged
 						}
 						if user.(UserPasser).GetLocked() {
 							err = ErrUserLocked
@@ -63,14 +105,26 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 					}
 				}
 				if err != nil {
-					log.Println(err)
+					if !mustLogin {
+						next.ServeHTTP(w, r)
+						return
+					}
 					switch err {
 					case ErrUserNotFound:
 						setFailCodeFlash(w, FailCodeUserNotFound)
 					case ErrUserLocked:
 						setFailCodeFlash(w, FailCodeUserLocked)
-					case ErrUserPassChanged:
-						setWarnCodeFlash(w, WarnCodePasswordHasBeenChanged)
+					case ErrPasswordChanged:
+						isSelfChange := false
+						if c, err := r.Cookie(infoCodeFlashCookieName); err == nil {
+							v, _ := strconv.Atoi(c.Value)
+							if InfoCode(v) == InfoCodePasswordSuccessfullyChanged {
+								isSelfChange = true
+							}
+						}
+						if !isSelfChange {
+							setWarnCodeFlash(w, WarnCodePasswordHasBeenChanged)
+						}
 					default:
 						setFailCodeFlash(w, FailCodeSystemError)
 					}
@@ -86,6 +140,10 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 					secureSalt = user.(SessionSecurer).GetSecure()
 					_, err := parseBaseClaimsFromCookie(r, b.authSecureCookieName, b.secret+secureSalt)
 					if err != nil {
+						if !mustLogin {
+							next.ServeHTTP(w, r)
+							return
+						}
 						if path == b.LogoutURL {
 							next.ServeHTTP(w, r)
 						} else {
@@ -103,6 +161,10 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 
 				claims.RegisteredClaims = b.genBaseSessionClaim(claims.UserID)
 				if err := b.setAuthCookiesFromUserClaims(w, claims, secureSalt); err != nil {
+					if !mustLogin {
+						next.ServeHTTP(w, r)
+						return
+					}
 					setFailCodeFlash(w, FailCodeSystemError)
 					if path == b.LogoutURL {
 						next.ServeHTTP(w, r)
@@ -115,7 +177,11 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 				if b.afterExtendSessionHook != nil {
 					setCookieForRequest(r, &http.Cookie{Name: b.authCookieName, Value: b.mustGetSessionToken(*claims)})
 					if herr := b.afterExtendSessionHook(r, user, oldSessionToken); herr != nil {
-						setFailCodeFlash(w, FailCodeSystemError)
+						if !mustLogin {
+							next.ServeHTTP(w, r)
+							return
+						}
+						setNoticeOrFailCodeFlash(w, herr, FailCodeSystemError)
 						http.Redirect(w, r, b.LogoutURL, http.StatusFound)
 						return
 					}
@@ -159,9 +225,11 @@ func Authenticate(b *Builder) func(next http.Handler) http.Handler {
 				}
 			}
 
-			if path == b.loginPageURL || path == b.totpSetupPageURL || path == b.totpValidatePageURL {
-				http.Redirect(w, r, b.homePageURLFunc(r, user), http.StatusFound)
-				return
+			if autoRedirectToHomePage {
+				if path == b.loginPageURL || path == b.totpSetupPageURL || path == b.totpValidatePageURL {
+					http.Redirect(w, r, b.homePageURLFunc(r, user), http.StatusFound)
+					return
+				}
 			}
 
 			next.ServeHTTP(w, r)
@@ -173,6 +241,8 @@ func GetCurrentUser(r *http.Request) (u interface{}) {
 	return r.Context().Value(UserKey)
 }
 
+// IsLoginWIP indicates whether the user is in an intermediate step of login process,
+// such as on the TOTP validation page
 func IsLoginWIP(r *http.Request) bool {
 	v, ok := r.Context().Value(loginWIPKey).(bool)
 	if !ok {

+ 21 - 0
login/view_common.go

@@ -2,6 +2,7 @@ package login
 
 import (
 	"fmt"
+	"net/http"
 
 	. "github.com/theplant/htmlgo"
 )
@@ -22,6 +23,26 @@ type ViewCommon struct {
 	ButtonClass  string
 }
 
+func (vc *ViewCommon) Notice(vh *ViewHelper, msgr *Messages, w http.ResponseWriter, r *http.Request) HTMLComponent {
+	var nn HTMLComponent
+	if n := vh.GetNoticeFlash(w, r); n != nil && n.Message != "" {
+		switch n.Level {
+		case NoticeLevel_Info:
+			nn = vc.InfoNotice(n.Message)
+		case NoticeLevel_Warn:
+			nn = vc.WarnNotice(n.Message)
+		case NoticeLevel_Error:
+			nn = vc.ErrNotice(n.Message)
+		}
+	}
+	return Components(
+		vc.ErrNotice(vh.GetFailFlashMessage(msgr, w, r)),
+		vc.WarnNotice(vh.GetWarnFlashMessage(msgr, w, r)),
+		vc.InfoNotice(vh.GetInfoFlashMessage(msgr, w, r)),
+		nn,
+	)
+}
+
 func (vc *ViewCommon) ErrNotice(msg string) HTMLComponent {
 	if msg == "" {
 		return nil

+ 24 - 6
login/view_helper.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/qor5/x/i18n"
 )
@@ -74,7 +75,7 @@ func (vh *ViewHelper) RecaptchaSiteKey() string {
 }
 
 func (vh *ViewHelper) TOTPIssuer() string {
-	return vh.b.totpIssuer
+	return vh.b.totpConfig.Issuer
 }
 
 func (vh *ViewHelper) FindUserByID(id string) (user interface{}, err error) {
@@ -126,18 +127,35 @@ func (vh *ViewHelper) GetInfoCodeFlash(w http.ResponseWriter, r *http.Request) I
 	return InfoCode(v)
 }
 
-func (vh *ViewHelper) GetCustomErrorMessageFlash(w http.ResponseWriter, r *http.Request) string {
-	c, err := r.Cookie(customErrorMessageFlashCookieName)
+func (vh *ViewHelper) GetNoticeFlash(w http.ResponseWriter, r *http.Request) *NoticeError {
+	c, err := r.Cookie(noticeFlashCookieName)
 	if err != nil {
-		return ""
+		return nil
+	}
+	var level NoticeLevel
+	var message string
+	{
+		vs := strings.SplitN(c.Value, "#", 2)
+		if len(vs) != 2 {
+			return nil
+		}
+		n, err := strconv.Atoi(vs[0])
+		if err != nil {
+			return nil
+		}
+		level = NoticeLevel(n)
+		message = vs[1]
 	}
 	http.SetCookie(w, &http.Cookie{
-		Name:     customErrorMessageFlashCookieName,
+		Name:     noticeFlashCookieName,
 		Path:     "/",
 		MaxAge:   -1,
 		HttpOnly: true,
 	})
-	return c.Value
+	return &NoticeError{
+		Level:   level,
+		Message: message,
+	}
 }
 
 func (vh *ViewHelper) GetWrongLoginInputFlash(w http.ResponseWriter, r *http.Request) WrongLoginInputFlash {

+ 15 - 37
login/views.go

@@ -47,15 +47,6 @@ func defaultLoginPage(vh *ViewHelper) web.PageFunc {
 		}
 		// i18n end
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
-		wMsg := vh.GetWarnFlashMessage(msgr, ctx.W, ctx.R)
-		iMsg := vh.GetInfoFlashMessage(msgr, ctx.W, ctx.R)
-		wIn := vh.GetWrongLoginInputFlash(ctx.W, ctx.R)
-
-		if iMsg != "" && vh.GetInfoCodeFlash(ctx.W, ctx.R) == InfoCodePasswordSuccessfullyChanged {
-			wMsg = ""
-		}
-
 		var oauthHTML HTMLComponent
 		if vh.OAuthEnabled() {
 			ul := Div().Class("flex flex-col justify-center mt-8 text-center")
@@ -76,6 +67,7 @@ func defaultLoginPage(vh *ViewHelper) web.PageFunc {
 			)
 		}
 
+		wIn := vh.GetWrongLoginInputFlash(ctx.W, ctx.R)
 		isRecaptchaEnabled := vh.RecaptchaEnabled()
 
 		var userPassHTML HTMLComponent
@@ -111,7 +103,7 @@ func defaultLoginPage(vh *ViewHelper) web.PageFunc {
 			)
 		}
 
-		r.PageTitle = "Sign In"
+		r.PageTitle = msgr.LoginPageTitle
 		var bodyForm HTMLComponent
 		bodyForm = Div(
 			userPassHTML,
@@ -135,9 +127,7 @@ function onSubmit(token) {
 	document.getElementById("login-form").submit();
 }
 `)),
-			DefaultViewCommon.ErrNotice(fMsg),
-			DefaultViewCommon.WarnNotice(wMsg),
-			DefaultViewCommon.InfoNotice(iMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			bodyForm,
 		)
 
@@ -149,7 +139,6 @@ func defaultForgetPasswordPage(vh *ViewHelper) web.PageFunc {
 	return func(ctx *web.EventContext) (r web.PageResponse, err error) {
 		msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
 		wIn := vh.GetWrongForgetPasswordInputFlash(ctx.W, ctx.R)
 		secondsToResend := vh.GetSecondsToRedoFlash(ctx.W, ctx.R)
 		activeBtnText := msgr.SendResetPasswordEmailBtn
@@ -166,7 +155,7 @@ func defaultForgetPasswordPage(vh *ViewHelper) web.PageFunc {
 
 		isRecaptchaEnabled := vh.RecaptchaEnabled()
 
-		r.PageTitle = "Forget Your Password?"
+		r.PageTitle = msgr.ForgetPasswordPageTitle
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
 			If(isRecaptchaEnabled,
@@ -178,7 +167,7 @@ function onSubmit(token) {
 	document.getElementById("forget-form").submit();
 }
 `)),
-			DefaultViewCommon.ErrNotice(fMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			If(secondsToResend > 0,
 				DefaultViewCommon.WarnNotice(msgr.SendEmailTooFrequentlyNotice),
 			),
@@ -247,9 +236,10 @@ func defaultResetPasswordLinkSentPage(vh *ViewHelper) web.PageFunc {
 
 		a := ctx.R.URL.Query().Get("a")
 
-		r.PageTitle = "Forget Your Password?"
+		r.PageTitle = msgr.ResetPasswordLinkSentPageTitle
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			Div(
 				H1(fmt.Sprintf("%s %s.", msgr.ResetPasswordLinkWasSentTo, a)).Class("leading-tight text-2xl mt-0 mb-4"),
 				H2(msgr.ResetPasswordLinkSentPrompt).Class("leading-tight text-1xl mt-0"),
@@ -263,10 +253,6 @@ func defaultResetPasswordPage(vh *ViewHelper) web.PageFunc {
 	return func(ctx *web.EventContext) (r web.PageResponse, err error) {
 		msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
-		if fMsg == "" {
-			fMsg = vh.GetCustomErrorMessageFlash(ctx.W, ctx.R)
-		}
 		wIn := vh.GetWrongResetPasswordInputFlash(ctx.W, ctx.R)
 
 		doTOTP := ctx.R.URL.Query().Get("totp") == "1"
@@ -277,7 +263,7 @@ func defaultResetPasswordPage(vh *ViewHelper) web.PageFunc {
 
 		var user interface{}
 
-		r.PageTitle = "Reset Password"
+		r.PageTitle = msgr.ResetPasswordPageTitle
 
 		query := ctx.R.URL.Query()
 		id := query.Get("id")
@@ -314,7 +300,7 @@ func defaultResetPasswordPage(vh *ViewHelper) web.PageFunc {
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
 			Script("").Src(ZxcvbnJSURL),
-			DefaultViewCommon.ErrNotice(fMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			Div(
 				H1(msgr.ResetYourPasswordTitle).Class(DefaultViewCommon.TitleClass),
 				Form(
@@ -351,18 +337,14 @@ func defaultChangePasswordPage(vh *ViewHelper) web.PageFunc {
 	return func(ctx *web.EventContext) (r web.PageResponse, err error) {
 		msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
-		if fMsg == "" {
-			fMsg = vh.GetCustomErrorMessageFlash(ctx.W, ctx.R)
-		}
 		wIn := vh.GetWrongChangePasswordInputFlash(ctx.W, ctx.R)
 
-		r.PageTitle = "Change Password"
+		r.PageTitle = msgr.ChangePasswordPageTitle
 
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
 			Script("").Src(ZxcvbnJSURL),
-			DefaultViewCommon.ErrNotice(fMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			Div(
 				H1(msgr.ChangePasswordTitle).Class(DefaultViewCommon.TitleClass),
 				Form(
@@ -401,8 +383,6 @@ func defaultTOTPSetupPage(vh *ViewHelper) web.PageFunc {
 	return func(ctx *web.EventContext) (r web.PageResponse, err error) {
 		msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
-
 		user := GetCurrentUser(ctx.R)
 		u := user.(UserPasser)
 
@@ -436,10 +416,10 @@ func defaultTOTPSetupPage(vh *ViewHelper) web.PageFunc {
 			return
 		}
 
-		r.PageTitle = "TOTP Setup"
+		r.PageTitle = msgr.TOTPSetupPageTitle
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
-			DefaultViewCommon.ErrNotice(fMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			Div(
 				Div(
 					H1(msgr.TOTPSetupTitle).
@@ -473,12 +453,10 @@ func defaultTOTPValidatePage(vh *ViewHelper) web.PageFunc {
 	return func(ctx *web.EventContext) (r web.PageResponse, err error) {
 		msgr := i18n.MustGetModuleMessages(ctx.R, I18nLoginKey, Messages_en_US).(*Messages)
 
-		fMsg := vh.GetFailFlashMessage(msgr, ctx.W, ctx.R)
-
-		r.PageTitle = "TOTP Validate"
+		r.PageTitle = msgr.TOTPValidatePageTitle
 		r.Body = Div(
 			Link(StyleCSSURL).Type("text/css").Rel("stylesheet"),
-			DefaultViewCommon.ErrNotice(fMsg),
+			DefaultViewCommon.Notice(vh, msgr, ctx.W, ctx.R),
 			Div(
 				Div(
 					H1(msgr.TOTPValidateTitle).