+794
@@ -0,0 +1,794 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/notifs"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/tmptokens"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/utils"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/zerodha/simplesessions/v3"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordResetTTL = 30 * time.Minute
|
||||
twofaTokenTTL = 5 * time.Minute
|
||||
|
||||
// Length of reset and 2FA auth tokens.
|
||||
tmpAuthTokenLen = 64
|
||||
)
|
||||
|
||||
type loginTpl struct {
|
||||
Title string
|
||||
Description string
|
||||
|
||||
NextURI string
|
||||
Nonce string
|
||||
PasswordEnabled bool
|
||||
OIDCProvider string
|
||||
OIDCProviderLogo string
|
||||
Error string
|
||||
}
|
||||
|
||||
type oidcState struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
type forgotPasswordTpl struct {
|
||||
Title string
|
||||
Description string
|
||||
Error string
|
||||
}
|
||||
|
||||
type resetPasswordTpl struct {
|
||||
Title string
|
||||
Description string
|
||||
Token string
|
||||
Email string
|
||||
Error string
|
||||
}
|
||||
|
||||
type twofaTpl struct {
|
||||
Title string
|
||||
Description string
|
||||
Token string
|
||||
NextURI string
|
||||
Error string
|
||||
}
|
||||
|
||||
var (
|
||||
oidcProviders = map[string]struct{}{
|
||||
"google.com": {},
|
||||
"microsoftonline.com": {},
|
||||
"auth0.com": {},
|
||||
"github.com": {},
|
||||
}
|
||||
)
|
||||
|
||||
// LoginPage renders the login page and handles the login form.
|
||||
func (a *App) LoginPage(c echo.Context) error {
|
||||
// Has the user been setup?
|
||||
a.Lock()
|
||||
needsUserSetup := a.needsUserSetup
|
||||
a.Unlock()
|
||||
|
||||
if needsUserSetup {
|
||||
return a.LoginSetupPage(c)
|
||||
}
|
||||
|
||||
// Process POST login request.
|
||||
var loginErr error
|
||||
if c.Request().Method == http.MethodPost {
|
||||
loginErr = a.doLogin(c)
|
||||
if loginErr == nil {
|
||||
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
|
||||
}
|
||||
}
|
||||
|
||||
// Render the page, with or without POST.
|
||||
return a.renderLoginPage(c, loginErr)
|
||||
}
|
||||
|
||||
// LoginSetupPage renders the first time user login page and handles the login form.
|
||||
func (a *App) LoginSetupPage(c echo.Context) error {
|
||||
// Process POST login request.
|
||||
var loginErr error
|
||||
if c.Request().Method == http.MethodPost {
|
||||
loginErr = a.doFirstTimeSetup(c)
|
||||
if loginErr == nil {
|
||||
a.Lock()
|
||||
a.needsUserSetup = false
|
||||
a.Unlock()
|
||||
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
|
||||
}
|
||||
}
|
||||
|
||||
// Render the page, with or without POST.
|
||||
return a.renderLoginSetupPage(c, loginErr)
|
||||
}
|
||||
|
||||
// TwofaPage renders the 2FA verification page and handles the 2FA form submission.
|
||||
func (a *App) TwofaPage(c echo.Context) error {
|
||||
var token, next string
|
||||
|
||||
if c.Request().Method == http.MethodPost {
|
||||
token = strings.TrimSpace(c.FormValue("token"))
|
||||
next = utils.SanitizeURI(c.FormValue("next"))
|
||||
} else {
|
||||
token = strings.TrimSpace(c.QueryParam("token"))
|
||||
next = utils.SanitizeURI(c.QueryParam("next"))
|
||||
}
|
||||
|
||||
// If there's no token, redirect.
|
||||
if len(token) < tmpAuthTokenLen {
|
||||
return c.Redirect(http.StatusFound, uriAdmin)
|
||||
}
|
||||
|
||||
if next == "" || next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
// Validate the 2FA temp token.
|
||||
data, err := tmptokens.Check(token)
|
||||
if err != nil {
|
||||
return c.Redirect(http.StatusFound, uriAdmin)
|
||||
}
|
||||
|
||||
userID, ok := data.(int)
|
||||
if !ok {
|
||||
return a.renderTwofaPage(c, token, next, a.i18n.T("users.invalidRequest"))
|
||||
}
|
||||
|
||||
// Process the 2FA verification POST request.
|
||||
if c.Request().Method == http.MethodPost {
|
||||
return a.doTwofaVerify(c, token, userID, next)
|
||||
}
|
||||
|
||||
// Render the 2FA verification page.
|
||||
return a.renderTwofaPage(c, token, next, "")
|
||||
}
|
||||
|
||||
// Logout logs a user out.
|
||||
func (a *App) Logout(c echo.Context) error {
|
||||
// Delete the session from the DB and cookie.
|
||||
sess := c.Get(auth.SessionKey).(*simplesessions.Session)
|
||||
_ = sess.Destroy()
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// OIDCLogin initializes an OIDC request and redirects to the OIDC provider for login.
|
||||
func (a *App) OIDCLogin(c echo.Context) error {
|
||||
// Verify that the request came from the login page (CSRF).
|
||||
nonce, err := c.Cookie("nonce")
|
||||
if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest"))
|
||||
}
|
||||
|
||||
// Sanitize the URL and make it relative.
|
||||
next := utils.SanitizeURI(c.FormValue("next"))
|
||||
if next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
// Preparethe OIDC payload to send to the provider.
|
||||
state := oidcState{Nonce: nonce.Value, Next: next}
|
||||
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
a.log.Printf("error marshalling OIDC state: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Redirect to the external OIDC provider.
|
||||
return c.Redirect(http.StatusFound, a.auth.GetOIDCAuthURL(base64.URLEncoding.EncodeToString(b), nonce.Value))
|
||||
}
|
||||
|
||||
// OIDCFinish receives the redirect callback from the OIDC provider and completes the handshake.
|
||||
func (a *App) OIDCFinish(c echo.Context) error {
|
||||
// Verify that the request actually originated from the login request (which sets the nonce value).
|
||||
nonce, err := c.Cookie("nonce")
|
||||
if err != nil || nonce.Value == "" {
|
||||
return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
|
||||
}
|
||||
|
||||
// Validate the OIDC token.
|
||||
oidcToken, claims, err := a.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value)
|
||||
if err != nil {
|
||||
return a.renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Validate the state.
|
||||
var state oidcState
|
||||
stateB, err := base64.URLEncoding.DecodeString(c.QueryParam("state"))
|
||||
if err != nil {
|
||||
a.log.Printf("error decoding OIDC state: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
if err := json.Unmarshal(stateB, &state); err != nil {
|
||||
a.log.Printf("error unmarshalling OIDC state: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
if state.Nonce != nonce.Value {
|
||||
return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
|
||||
}
|
||||
|
||||
// Validate e-mail from the claim.
|
||||
email := strings.TrimSpace(claims.Email)
|
||||
if email == "" {
|
||||
return a.renderLoginPage(c, errors.New(a.i18n.Ts("globals.messages.invalidFields", "name", "email")))
|
||||
}
|
||||
em, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return a.renderLoginPage(c, err)
|
||||
}
|
||||
email = strings.ToLower(em.Address)
|
||||
claims.Email = email
|
||||
|
||||
// Get the user by e-mail received from OIDC.
|
||||
user, userErr := a.core.GetUser(0, "", email)
|
||||
if userErr != nil {
|
||||
// If the user doesn't exist, and auto-creation is enabled, create a new user.
|
||||
if httpErr, ok := userErr.(*echo.HTTPError); ok && httpErr.Code == http.StatusNotFound && a.cfg.Security.OIDC.AutoCreateUsers {
|
||||
u, err := a.createOIDCUser(claims, c)
|
||||
if err != nil {
|
||||
return a.renderLoginPage(c, err)
|
||||
}
|
||||
user = u
|
||||
userErr = nil
|
||||
} else {
|
||||
return a.renderLoginPage(c, userErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the user login state (avatar, logged in date) in the DB.
|
||||
if err := a.core.UpdateUserLogin(user.ID, claims.Picture); err != nil {
|
||||
return a.renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Set the session in the DB and cookie.
|
||||
if err := a.auth.SaveSession(user, oidcToken, c); err != nil {
|
||||
return a.renderLoginPage(c, err)
|
||||
}
|
||||
|
||||
// Redirect to the next page.
|
||||
return c.Redirect(http.StatusFound, utils.SanitizeURI(state.Next))
|
||||
}
|
||||
|
||||
// ForgotPage renders the forgot password page and handles the forgot password form.
|
||||
func (a *App) ForgotPage(c echo.Context) error {
|
||||
// Process the forgot password request.
|
||||
if c.Request().Method == http.MethodPost {
|
||||
return a.doForgotPassword(c)
|
||||
}
|
||||
|
||||
// Render the forgot page.
|
||||
out := forgotPasswordTpl{Title: a.i18n.T("users.forgotPassword")}
|
||||
return c.Render(http.StatusOK, "admin-forgot-password", out)
|
||||
}
|
||||
|
||||
// ResetPage renders the reset password page and handles the reset password form.
|
||||
func (a *App) ResetPage(c echo.Context) error {
|
||||
var (
|
||||
token = strings.TrimSpace(c.QueryParam("token"))
|
||||
email = strings.ToLower(strings.TrimSpace(c.QueryParam("email")))
|
||||
)
|
||||
|
||||
// Validate token and email (don't delete it yet, as we may need it for POST).
|
||||
data, err := tmptokens.Check(email)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
tk, ok := data.(string)
|
||||
if !ok || tk != token {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
// Validate that the user exists.
|
||||
_, err = a.core.GetUser(0, "", email)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
// Process the reset password request form with the new passwords.
|
||||
if c.Request().Method == http.MethodPost {
|
||||
return a.doResetPassword(c, token, email)
|
||||
}
|
||||
|
||||
// Render the reset password form for GET request.
|
||||
return a.renderResetPasswordPage(c, token, email, "")
|
||||
}
|
||||
|
||||
// renderLoginPage renders the login page and handles the login form.
|
||||
func (a *App) renderLoginPage(c echo.Context, loginErr error) error {
|
||||
next := utils.SanitizeURI(c.FormValue("next"))
|
||||
if next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
var (
|
||||
oidcProviderName = ""
|
||||
oidcLogo = ""
|
||||
)
|
||||
if a.cfg.Security.OIDC.Enabled {
|
||||
// Defaults.
|
||||
oidcProviderName = a.cfg.Security.OIDC.ProviderName
|
||||
oidcLogo = "oidc.png"
|
||||
|
||||
u, err := url.Parse(a.cfg.Security.OIDC.ProviderURL)
|
||||
if err == nil {
|
||||
h := strings.Split(u.Hostname(), ".")
|
||||
|
||||
// Get the last two h for the root domain
|
||||
prov := ""
|
||||
if len(h) >= 2 {
|
||||
prov = h[len(h)-2] + "." + h[len(h)-1]
|
||||
} else {
|
||||
prov = u.Hostname()
|
||||
}
|
||||
|
||||
if oidcProviderName == "" {
|
||||
oidcProviderName = prov
|
||||
}
|
||||
|
||||
// Lookup the logo in the known providers map.
|
||||
if _, ok := oidcProviders[prov]; ok {
|
||||
oidcLogo = prov + ".png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := loginTpl{
|
||||
Title: a.i18n.T("users.login"),
|
||||
PasswordEnabled: true,
|
||||
OIDCProvider: oidcProviderName,
|
||||
OIDCProviderLogo: oidcLogo,
|
||||
NextURI: next,
|
||||
}
|
||||
|
||||
// If there was an error in the previous state (POST reqest), set it to render in the template.
|
||||
if loginErr != nil {
|
||||
if e, ok := loginErr.(*echo.HTTPError); ok {
|
||||
out.Error = e.Message.(string)
|
||||
} else {
|
||||
out.Error = loginErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and set a nonce for preventing CSRF requests that will be valided in the subsequent requests.
|
||||
nonce, err := utils.GenerateRandomString(16)
|
||||
if err != nil {
|
||||
a.log.Printf("error generating OIDC nonce: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "nonce",
|
||||
Value: nonce,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
out.Nonce = nonce
|
||||
|
||||
// Render the login page.
|
||||
return c.Render(http.StatusOK, "admin-login", out)
|
||||
}
|
||||
|
||||
// renderLoginSetupPage renders the first time user setup page.
|
||||
func (a *App) renderLoginSetupPage(c echo.Context, loginErr error) error {
|
||||
next := utils.SanitizeURI(c.FormValue("next"))
|
||||
if next == "/" {
|
||||
next = uriAdmin
|
||||
}
|
||||
|
||||
out := loginTpl{
|
||||
Title: a.i18n.T("users.login"),
|
||||
PasswordEnabled: true,
|
||||
NextURI: next,
|
||||
}
|
||||
|
||||
// If there was an error in the previous state (POST reqest), set it to render in the template.
|
||||
if loginErr != nil {
|
||||
if e, ok := loginErr.(*echo.HTTPError); ok {
|
||||
out.Error = e.Message.(string)
|
||||
} else {
|
||||
out.Error = loginErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "admin-login-setup", out)
|
||||
}
|
||||
|
||||
// createOIDCUser creates a new user in the DB with the OIDC claims.
|
||||
func (a *App) createOIDCUser(claims auth.OIDCclaim, c echo.Context) (auth.User, error) {
|
||||
name := claims.Name
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(claims.PreferredUsername)
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.Split(claims.Email, "@")[0]
|
||||
}
|
||||
|
||||
var listRoleID *int
|
||||
if a.cfg.Security.OIDC.DefaultListRoleID > 0 {
|
||||
listRoleID = &a.cfg.Security.OIDC.DefaultListRoleID
|
||||
}
|
||||
|
||||
user, err := a.core.CreateUser(auth.User{
|
||||
Type: auth.UserTypeUser,
|
||||
HasPassword: false,
|
||||
PasswordLogin: false,
|
||||
Username: claims.Email,
|
||||
Name: name,
|
||||
Email: null.NewString(claims.Email, true),
|
||||
UserRoleID: a.cfg.Security.OIDC.DefaultUserRoleID,
|
||||
ListRoleID: listRoleID,
|
||||
Status: auth.UserStatusEnabled,
|
||||
})
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
// doLogin logs a user in with a username and password.
|
||||
func (a *App) doLogin(c echo.Context) error {
|
||||
var (
|
||||
startTime = time.Now()
|
||||
username = strings.TrimSpace(c.FormValue("username"))
|
||||
password = strings.TrimSpace(c.FormValue("password"))
|
||||
)
|
||||
|
||||
// Ensure timing mitigation is applied regardless of early returns
|
||||
defer func() {
|
||||
if elapsed := time.Since(startTime).Milliseconds(); elapsed < 100 {
|
||||
time.Sleep(time.Duration(100-elapsed) * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
if !strHasLen(username, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !strHasLen(password, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
|
||||
// Log the user in by fetching and verifying credentials from the DB.
|
||||
user, err := a.core.LoginUser(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If TOTP is enabled for the user, create a temp token and redirect to the 2FA page.
|
||||
if user.TwofaType == models.TwofaTypeTOTP {
|
||||
// Generate a random token.
|
||||
token, err := generateRandomString(tmpAuthTokenLen)
|
||||
if err != nil {
|
||||
a.log.Printf("error generating 2FA token: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Set the token.
|
||||
tmptokens.Set(token, twofaTokenTTL, user.ID)
|
||||
|
||||
// Redirect to 2FA page.
|
||||
next := utils.SanitizeURI(c.FormValue("next"))
|
||||
return c.Redirect(http.StatusFound, fmt.Sprintf("%s/login/twofa?token=%s&next=%s", uriAdmin, token, url.QueryEscape(next)))
|
||||
}
|
||||
|
||||
// Set the session in the DB and cookie.
|
||||
if err := a.auth.SaveSession(user, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// doFirstTimeSetup sets a user up for the first time.
|
||||
func (a *App) doFirstTimeSetup(c echo.Context) error {
|
||||
var (
|
||||
email = strings.TrimSpace(c.FormValue("email"))
|
||||
username = strings.TrimSpace(c.FormValue("username"))
|
||||
password = strings.TrimSpace(c.FormValue("password"))
|
||||
password2 = strings.TrimSpace(c.FormValue("password2"))
|
||||
)
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
if !strHasLen(username, 3, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !strHasLen(password, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
if password != password2 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.passwordMismatch"))
|
||||
}
|
||||
|
||||
// Create the default "Super Admin" with all permissions if it doesn't exist.
|
||||
if _, err := a.core.GetRole(auth.SuperAdminRoleID); err != nil {
|
||||
r := auth.Role{
|
||||
Type: auth.RoleTypeUser,
|
||||
Name: null.NewString("Super Admin", true),
|
||||
}
|
||||
for p := range a.cfg.Permissions {
|
||||
r.Permissions = append(r.Permissions, p)
|
||||
}
|
||||
|
||||
// Create the role in the DB.
|
||||
if _, err := a.core.CreateRole(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the super admin user in the DB.
|
||||
u := auth.User{
|
||||
Type: auth.UserTypeUser,
|
||||
HasPassword: true,
|
||||
PasswordLogin: true,
|
||||
Username: username,
|
||||
Name: username,
|
||||
Password: null.NewString(password, true),
|
||||
Email: null.NewString(email, true),
|
||||
UserRoleID: auth.SuperAdminRoleID,
|
||||
Status: auth.UserStatusEnabled,
|
||||
}
|
||||
if _, err := a.core.CreateUser(u); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log the user in directly.
|
||||
user, err := a.core.LoginUser(username, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the session in the DB and cookie.
|
||||
if err := a.auth.SaveSession(user, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderResetPasswordPage renders the reset password page.
|
||||
func (a *App) renderResetPasswordPage(c echo.Context, token, email, errMsg string) error {
|
||||
out := resetPasswordTpl{
|
||||
Title: a.i18n.T("users.resetPassword"),
|
||||
Token: token,
|
||||
Email: email,
|
||||
Error: errMsg,
|
||||
}
|
||||
return c.Render(http.StatusOK, "admin-reset-password", out)
|
||||
}
|
||||
|
||||
// doForgotPassword handles the forgot password form submission.
|
||||
func (a *App) doForgotPassword(c echo.Context) error {
|
||||
var (
|
||||
email = strings.ToLower(strings.TrimSpace(c.FormValue("email")))
|
||||
)
|
||||
|
||||
// Validate email format.
|
||||
if !utils.ValidateEmail(email) {
|
||||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
|
||||
}
|
||||
|
||||
// Get the user by email.
|
||||
user, err := a.core.GetUser(0, "", email)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
|
||||
}
|
||||
|
||||
// If the password login is disabled, do not proceed, but show success message to prevent email enumeration.
|
||||
if !user.PasswordLogin {
|
||||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
|
||||
}
|
||||
|
||||
// Generate a random token.
|
||||
token, err := generateRandomString(tmpAuthTokenLen)
|
||||
if err != nil {
|
||||
a.log.Printf("error generating reset token: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Store the reset token in tmptokens.
|
||||
tmptokens.Set(email, passwordResetTTL, token)
|
||||
|
||||
// Prepare the reset URL.
|
||||
resetURL := fmt.Sprintf("%s/admin/reset?token=%s&email=%s", a.urlCfg.RootURL, token, url.QueryEscape(email))
|
||||
|
||||
// Prepare the email.
|
||||
var msg bytes.Buffer
|
||||
data := struct {
|
||||
ResetURL string
|
||||
L *i18n.I18n
|
||||
}{
|
||||
ResetURL: resetURL,
|
||||
L: a.i18n,
|
||||
}
|
||||
|
||||
// Render the email template.
|
||||
if err := notifs.Tpls.ExecuteTemplate(&msg, notifs.TplForgotPassword, data); err != nil {
|
||||
a.log.Printf("error compiling notification template '%s': %v", notifs.TplForgotPassword, err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
subject, body := notifs.GetTplSubject(a.i18n.T("email.forgotPassword.subject"), msg.Bytes())
|
||||
|
||||
// Send the email.
|
||||
if err := a.emailMsgr.Push(models.Message{
|
||||
From: a.cfg.FromEmail,
|
||||
To: []string{email},
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
}); err != nil {
|
||||
a.log.Printf("error sending reset email: %s", err)
|
||||
}
|
||||
|
||||
// Show the success e-mail nonetheless to prevent e-mail enumeration.
|
||||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.resetLinkSent")))
|
||||
}
|
||||
|
||||
// doResetPassword handles the reset password form submission.
|
||||
func (a *App) doResetPassword(c echo.Context, token, email string) error {
|
||||
var (
|
||||
password = c.FormValue("password")
|
||||
password2 = c.FormValue("password2")
|
||||
)
|
||||
|
||||
// Validate password.
|
||||
if !strHasLen(password, 8, stdInputMaxLen) {
|
||||
return a.renderResetPasswordPage(c, token, email, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
if password != password2 {
|
||||
return a.renderResetPasswordPage(c, token, email, a.i18n.T("users.passwordMismatch"))
|
||||
}
|
||||
|
||||
// Validate and consume the token (this deletes it).
|
||||
data, err := tmptokens.Get(email)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
tk, ok := data.(string)
|
||||
if !ok || tk != token {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
// Get the user.
|
||||
user, err := a.core.GetUser(0, "", email)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("users.invalidResetLink")))
|
||||
}
|
||||
|
||||
// Password login is disabled for the user.
|
||||
if !user.PasswordLogin {
|
||||
return c.Render(http.StatusBadRequest, tplMessage, makeMsgTpl(a.i18n.T("users.resetPassword"), "", a.i18n.T("public.invalidFeature")))
|
||||
}
|
||||
|
||||
user.Password = null.NewString(password, true)
|
||||
if _, err := a.core.UpdateUserProfile(user.ID, user); err != nil {
|
||||
a.log.Printf("error updating user password: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Invalidate all existing sessions for the user after password reset.
|
||||
if err := a.core.DeleteUserSessions(user.ID, ""); err != nil {
|
||||
a.log.Printf("error destroying sessions after password reset for user_id=%d: %v", user.ID, err)
|
||||
}
|
||||
|
||||
// Log the user in directly without forcing a manual login right after password change.
|
||||
if err := a.auth.SaveSession(user, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Redirect to the admin page.
|
||||
return c.Redirect(http.StatusFound, uriAdmin)
|
||||
}
|
||||
|
||||
// renderTwofaPage renders the 2FA verification page.
|
||||
func (a *App) renderTwofaPage(c echo.Context, token, next, errMsg string) error {
|
||||
out := twofaTpl{
|
||||
Title: a.i18n.T("users.twoFA"),
|
||||
Description: "",
|
||||
Token: token,
|
||||
NextURI: next,
|
||||
Error: errMsg,
|
||||
}
|
||||
return c.Render(http.StatusOK, "admin-twofa", out)
|
||||
}
|
||||
|
||||
// doTwofaVerify handles the 2FA verification form submission.
|
||||
func (a *App) doTwofaVerify(c echo.Context, token string, userID int, next string) error {
|
||||
totpCode := strings.TrimSpace(c.FormValue("totp_code"))
|
||||
|
||||
// Validate.
|
||||
if !strHasLen(totpCode, 6, 6) {
|
||||
return a.renderTwofaPage(c, token, next, a.i18n.T("globals.messages.invalidValue"))
|
||||
}
|
||||
|
||||
// Get the user.
|
||||
user, err := a.core.GetUser(userID, "", "")
|
||||
if err != nil {
|
||||
return a.renderTwofaPage(c, token, next, a.i18n.T("users.invalidRequest"))
|
||||
}
|
||||
|
||||
// Verify that TOTP is actually enabled for the user.
|
||||
if user.TwofaType != models.TwofaTypeTOTP {
|
||||
return a.renderTwofaPage(c, token, next, a.i18n.T("users.twoFANotEnabled"))
|
||||
}
|
||||
|
||||
// Verify the TOTP code.
|
||||
valid := totp.Validate(totpCode, user.TwofaKey.String)
|
||||
if !valid {
|
||||
return a.renderTwofaPage(c, token, next, a.i18n.T("globals.messages.invalidValue"))
|
||||
}
|
||||
|
||||
// Invalidate the token.
|
||||
tmptokens.Delete(token)
|
||||
|
||||
// Set the session.
|
||||
if err := a.auth.SaveSession(user, "", c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Redirect to the next page.
|
||||
return c.Redirect(http.StatusFound, next)
|
||||
}
|
||||
|
||||
// GenerateTOTPQR generates a TOTP QR code for a user to scan with their authenticator app.
|
||||
func (a *App) GenerateTOTPQR(c echo.Context) error {
|
||||
u := c.Get(auth.UserHTTPCtxKey).(auth.User)
|
||||
|
||||
// If TOTP is already enabled, don't generate a new key.
|
||||
if u.TwofaType == models.TwofaTypeTOTP {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.twoFAAlreadyEnabled"))
|
||||
}
|
||||
|
||||
// Generate a new TOTP key.
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: a.cfg.SiteName,
|
||||
AccountName: u.Email.String,
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Printf("error generating TOTP key: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Convert the TOTP key to a QR code image.
|
||||
img, err := key.Image(200, 200)
|
||||
if err != nil {
|
||||
a.log.Printf("error generating QR code: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
// Encode the QR code as a PNG and return it as base64.
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
a.log.Printf("error encoding QR code: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
Secret string `json:"secret"`
|
||||
QR string `json:"qr"`
|
||||
}{
|
||||
Secret: key.Secret(),
|
||||
QR: base64.StdEncoding.EncodeToString(buf.Bytes()),
|
||||
}})
|
||||
}
|
||||
Reference in New Issue
Block a user