mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-02 17:20:53 +02:00
Add user session management and auditing
This commit is contained in:
25 files changed
+1299
-11
No files matched your search
@@ -0,0 +1,148 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/session"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const tplUserSessions templates.TplName = "admin/user/sessions"
|
||||
|
||||
// UserSessions shows all sessions for a user
|
||||
func UserSessions(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.Redirect(setting.AppSubURL + "/-/admin/users")
|
||||
} else {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Title"] = fmt.Sprintf("%s - %s", ctx.Tr("admin.users.details"), ctx.Tr("settings.sessions"))
|
||||
ctx.Data["PageIsAdminUsers"] = true
|
||||
ctx.Data["User"] = u
|
||||
|
||||
sessions, err := auth_model.GetUserSessionsByUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserSessionsByUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Sessions"] = sessions
|
||||
ctx.Data["SessionsTotal"] = len(sessions)
|
||||
|
||||
activeCount := 0
|
||||
for _, s := range sessions {
|
||||
if s.LogoutUnix == 0 {
|
||||
activeCount++
|
||||
}
|
||||
}
|
||||
ctx.Data["ActiveCount"] = activeCount
|
||||
|
||||
ctx.HTML(http.StatusOK, tplUserSessions)
|
||||
}
|
||||
|
||||
// RevokeUserSession revokes a single session for a user (admin action)
|
||||
func RevokeUserSession(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
form := web.GetForm(ctx).(*forms.RevokeSessionForm)
|
||||
if form.SessionID == "" {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the session belongs to the target user
|
||||
sess, err := auth_model.GetUserSessionByID(ctx, form.SessionID)
|
||||
if err != nil {
|
||||
if auth_model.IsErrUserSessionNotExist(err) {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID))
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetUserSessionByID", err)
|
||||
return
|
||||
}
|
||||
if sess.UserID != u.ID {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth_model.InvalidateUserSession(ctx, form.SessionID); err != nil {
|
||||
ctx.ServerError("InvalidateUserSession", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := session.DestroySessionByID(form.SessionID); err != nil {
|
||||
log.Error("Failed to destroy chi-session %s: %v", form.SessionID, err)
|
||||
}
|
||||
|
||||
// Delete the specific remember-me auth token so the browser can't auto-sign back in
|
||||
if sess.AuthTokenID != "" {
|
||||
if err := auth_model.DeleteAuthTokenByID(ctx, sess.AuthTokenID); err != nil {
|
||||
log.Error("Failed to delete auth token %s: %v", sess.AuthTokenID, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_success"))
|
||||
ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID))
|
||||
}
|
||||
|
||||
// RevokeAllUserSessions revokes all sessions for a user (admin action)
|
||||
func RevokeAllUserSessions(ctx *context.Context) {
|
||||
u, err := user_model.GetUserByID(ctx, ctx.PathParamInt64("userid"))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserByID", err)
|
||||
return
|
||||
}
|
||||
|
||||
sessions, err := auth_model.GetUserSessionsByUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserSessionsByUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy active chi-sessions first, then bulk-update DB metadata.
|
||||
// DestroySessionByID is what actually revokes the session; InvalidateAllUserSessions
|
||||
// only sets logout_unix for audit purposes (there is no per-request DB validity check).
|
||||
for _, s := range sessions {
|
||||
if s.LogoutUnix == 0 {
|
||||
if err := session.DestroySessionByID(s.ID); err != nil {
|
||||
log.Error("Failed to destroy chi-session %s: %v", s.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := auth_model.InvalidateAllUserSessions(ctx, u.ID, ""); err != nil {
|
||||
ctx.ServerError("InvalidateAllUserSessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete remember-me auth tokens so revoked sessions can't be restored
|
||||
if err := auth_model.DeleteAuthTokensByUserID(ctx, u.ID); err != nil {
|
||||
log.Error("Failed to delete auth tokens for user %d: %v", u.ID, err)
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_all_success"))
|
||||
ctx.Redirect(fmt.Sprintf("%s/-/admin/users/%d/sessions", setting.AppSubURL, u.ID))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -126,6 +127,24 @@ func autoSignIn(ctx *context.Context) (bool, error) {
|
||||
return false, fmt.Errorf("unable to updateSession: %w", err)
|
||||
}
|
||||
|
||||
// Create tracked user session record for remember-me login
|
||||
ip := ctx.RemoteAddr()
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
if err := auth.CreateUserSession(ctx, &auth.UserSession{
|
||||
ID: ctx.Session.ID(),
|
||||
UserID: u.ID,
|
||||
LoginIP: ip,
|
||||
LastIP: ip,
|
||||
UserAgent: ctx.Req.UserAgent(),
|
||||
LoginMethod: "remember_me",
|
||||
AuthTokenID: nt.ID,
|
||||
LastAccessUnix: timeutil.TimeStampNow(),
|
||||
}); err != nil {
|
||||
log.Error("Failed to create user session record: %v", err)
|
||||
}
|
||||
|
||||
if err := resetLocale(ctx, u); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -326,6 +345,7 @@ func SignInPost(ctx *context.Context) {
|
||||
// User will need to use 2FA TOTP or WebAuthn, save data
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": form.Remember,
|
||||
"_loginMethod": "password",
|
||||
}
|
||||
if hasTOTPtwofa {
|
||||
// User will need to use WebAuthn, save data
|
||||
@@ -356,13 +376,14 @@ func handleSignIn(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
}
|
||||
|
||||
func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
var authTokenID string
|
||||
if remember {
|
||||
nt, token, err := auth_service.CreateAuthTokenForUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("CreateAuthTokenForUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
authTokenID = nt.ID
|
||||
ctx.SetSiteCookie(setting.CookieRememberName, nt.ID+":"+token, setting.LogInRememberDays*timeutil.Day)
|
||||
}
|
||||
|
||||
@@ -391,6 +412,29 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create tracked user session record
|
||||
loginMethod := "password"
|
||||
if method, ok := ctx.Session.Get("_loginMethod").(string); ok && method != "" {
|
||||
loginMethod = method
|
||||
}
|
||||
_ = ctx.Session.Delete("_loginMethod")
|
||||
ip := ctx.RemoteAddr()
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
if err := auth.CreateUserSession(ctx, &auth.UserSession{
|
||||
ID: ctx.Session.ID(),
|
||||
UserID: u.ID,
|
||||
LoginIP: ip,
|
||||
LastIP: ip,
|
||||
UserAgent: ctx.Req.UserAgent(),
|
||||
LoginMethod: loginMethod,
|
||||
AuthTokenID: authTokenID,
|
||||
LastAccessUnix: timeutil.TimeStampNow(),
|
||||
}); err != nil {
|
||||
log.Error("Failed to create user session record: %v", err)
|
||||
}
|
||||
|
||||
// Language setting of the user overwrites the one previously set
|
||||
// If the user does not have a locale set, we save the current one.
|
||||
if u.Language == "" {
|
||||
@@ -436,6 +480,9 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) {
|
||||
|
||||
// HandleSignOut resets the session and sets the cookies
|
||||
func HandleSignOut(ctx *context.Context) {
|
||||
if err := auth.InvalidateUserSession(ctx, ctx.Session.ID()); err != nil {
|
||||
log.Error("Failed to invalidate user session: %v", err)
|
||||
}
|
||||
_ = ctx.Session.Flush()
|
||||
_ = ctx.Session.Destroy(ctx.Resp, ctx.Req)
|
||||
ctx.DeleteSiteCookie(setting.CookieRememberName)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/session"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
source_service "code.gitea.io/gitea/services/auth/source"
|
||||
"code.gitea.io/gitea/services/auth/source/oauth2"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
@@ -391,6 +393,23 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
return
|
||||
}
|
||||
|
||||
// Create tracked user session record for OAuth2 login
|
||||
ip := ctx.RemoteAddr()
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
if err := auth.CreateUserSession(ctx, &auth.UserSession{
|
||||
ID: ctx.Session.ID(),
|
||||
UserID: u.ID,
|
||||
LoginIP: ip,
|
||||
LastIP: ip,
|
||||
UserAgent: ctx.Req.UserAgent(),
|
||||
LoginMethod: "oauth2:" + authSource.Name,
|
||||
LastAccessUnix: timeutil.TimeStampNow(),
|
||||
}); err != nil {
|
||||
log.Error("Failed to create user session record: %v", err)
|
||||
}
|
||||
|
||||
if err := resetLocale(ctx, u); err != nil {
|
||||
ctx.ServerError("resetLocale", err)
|
||||
return
|
||||
@@ -411,6 +430,7 @@ func handleOAuth2SignIn(ctx *context.Context, authSource *auth.Source, u *user_m
|
||||
// User needs to use 2FA, save data and redirect to 2FA page.
|
||||
"twofaUid": u.ID,
|
||||
"twofaRemember": false,
|
||||
"_loginMethod": "oauth2:" + authSource.Name,
|
||||
}); err != nil {
|
||||
ctx.ServerError("updateSession", err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
auth_model "code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/session"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
"code.gitea.io/gitea/services/forms"
|
||||
)
|
||||
|
||||
const tplSettingsSecuritySessions templates.TplName = "user/settings/security/sessions"
|
||||
|
||||
// Sessions renders the user's active sessions page
|
||||
func Sessions(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("settings.sessions")
|
||||
ctx.Data["PageIsSettingsSecurity"] = true
|
||||
|
||||
sessions, err := auth_model.GetUserSessionsByUserID(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserSessionsByUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Data["Sessions"] = sessions
|
||||
ctx.Data["CurrentSessionID"] = ctx.Session.ID()
|
||||
|
||||
otherActive := 0
|
||||
for _, s := range sessions {
|
||||
if s.LogoutUnix == 0 && s.ID != ctx.Session.ID() {
|
||||
otherActive++
|
||||
}
|
||||
}
|
||||
ctx.Data["OtherActiveCount"] = otherActive
|
||||
|
||||
ctx.HTML(http.StatusOK, tplSettingsSecuritySessions)
|
||||
}
|
||||
|
||||
// RevokeSession revokes a single user session
|
||||
func RevokeSession(ctx *context.Context) {
|
||||
form := web.GetForm(ctx).(*forms.RevokeSessionForm)
|
||||
if form.SessionID == "" {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the session belongs to the current user
|
||||
sess, err := auth_model.GetUserSessionByID(ctx, form.SessionID)
|
||||
if err != nil {
|
||||
if auth_model.IsErrUserSessionNotExist(err) {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions")
|
||||
return
|
||||
}
|
||||
ctx.ServerError("GetUserSessionByID", err)
|
||||
return
|
||||
}
|
||||
if sess.UserID != ctx.Doer.ID {
|
||||
ctx.Flash.Error(ctx.Tr("settings.sessions.session_not_found"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as logged out
|
||||
if err := auth_model.InvalidateUserSession(ctx, form.SessionID); err != nil {
|
||||
ctx.ServerError("InvalidateUserSession", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy the chi-session record via the provider
|
||||
if err := session.DestroySessionByID(form.SessionID); err != nil {
|
||||
log.Error("Failed to destroy chi-session %s: %v", form.SessionID, err)
|
||||
}
|
||||
|
||||
// Delete the specific remember-me auth token so the browser can't auto-sign back in
|
||||
if sess.AuthTokenID != "" {
|
||||
if err := auth_model.DeleteAuthTokenByID(ctx, sess.AuthTokenID); err != nil {
|
||||
log.Error("Failed to delete auth token %s: %v", sess.AuthTokenID, err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions")
|
||||
}
|
||||
|
||||
// RevokeAllSessions revokes all sessions except the current one
|
||||
func RevokeAllSessions(ctx *context.Context) {
|
||||
currentSessionID := ctx.Session.ID()
|
||||
|
||||
// Get all active sessions for the user to destroy their chi-sessions
|
||||
sessions, err := auth_model.GetUserSessionsByUserID(ctx, ctx.Doer.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserSessionsByUserID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Destroy active chi-sessions first, then bulk-update DB metadata.
|
||||
// DestroySessionByID is what actually revokes the session; InvalidateAllUserSessions
|
||||
// only sets logout_unix for audit purposes (there is no per-request DB validity check).
|
||||
for _, s := range sessions {
|
||||
if s.ID != currentSessionID && s.LogoutUnix == 0 {
|
||||
if err := session.DestroySessionByID(s.ID); err != nil {
|
||||
log.Error("Failed to destroy chi-session %s: %v", s.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate all sessions except current
|
||||
if err := auth_model.InvalidateAllUserSessions(ctx, ctx.Doer.ID, currentSessionID); err != nil {
|
||||
ctx.ServerError("InvalidateAllUserSessions", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete remember-me auth tokens so revoked sessions can't be restored
|
||||
if err := auth_model.DeleteAuthTokensByUserID(ctx, ctx.Doer.ID); err != nil {
|
||||
log.Error("Failed to delete auth tokens for user %d: %v", ctx.Doer.ID, err)
|
||||
}
|
||||
|
||||
ctx.Flash.Success(ctx.Tr("settings.sessions.revoke_all_success"))
|
||||
ctx.Redirect(setting.AppSubURL + "/user/settings/security/sessions")
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
"code.gitea.io/gitea/modules/web"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
@@ -97,6 +99,30 @@ type AuthMiddleware struct {
|
||||
MiddlewareHandler func(*context.Context)
|
||||
}
|
||||
|
||||
const sessionActivityThreshold = 5 * 60 // 5 minutes in seconds
|
||||
|
||||
// updateUserSessionActivity updates the user session's last access time with throttling.
|
||||
// It reads "_last_tracked" from session data (already loaded, no extra DB read) and
|
||||
// only writes to DB if more than 5 minutes have elapsed. The DB write is a single
|
||||
// UPDATE statement with no prior SELECT.
|
||||
func updateUserSessionActivity(ctx *context.Context) {
|
||||
now := timeutil.TimeStampNow()
|
||||
if lastTracked, ok := ctx.Session.Get("_last_tracked").(int64); ok {
|
||||
if int64(now)-lastTracked < sessionActivityThreshold {
|
||||
return
|
||||
}
|
||||
}
|
||||
ip := ctx.RemoteAddr()
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil {
|
||||
ip = host
|
||||
}
|
||||
if err := auth_model.UpdateSessionActivity(ctx, ctx.Session.ID(), ip); err != nil {
|
||||
log.Error("Failed to update session activity: %v", err)
|
||||
return
|
||||
}
|
||||
_ = ctx.Session.Set("_last_tracked", int64(now))
|
||||
}
|
||||
|
||||
func newWebAuthMiddleware() *AuthMiddleware {
|
||||
type keyAllowOAuth2 struct{}
|
||||
type keyAllowBasic struct{}
|
||||
@@ -161,6 +187,11 @@ func newWebAuthMiddleware() *AuthMiddleware {
|
||||
// ensure the session uid is deleted
|
||||
_ = ctx.Session.Delete("uid")
|
||||
}
|
||||
|
||||
// Throttled session activity tracking for signed-in web sessions
|
||||
if ctx.IsSigned && !ctx.IsBasicAuth {
|
||||
updateUserSessionActivity(ctx)
|
||||
}
|
||||
}
|
||||
return webAuth
|
||||
}
|
||||
@@ -650,6 +681,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/toggle_visibility", security.ToggleOpenIDVisibility)
|
||||
}, openIDSignInEnabled)
|
||||
m.Post("/account_link", security.DeleteAccountLink)
|
||||
m.Group("/sessions", func() {
|
||||
m.Get("", security.Sessions)
|
||||
m.Post("/revoke", web.Bind(forms.RevokeSessionForm{}), security.RevokeSession)
|
||||
m.Post("/revoke_all", security.RevokeAllSessions)
|
||||
})
|
||||
})
|
||||
|
||||
m.Group("/applications", func() {
|
||||
@@ -783,6 +819,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
||||
m.Post("/{userid}/delete", admin.DeleteUser)
|
||||
m.Post("/{userid}/avatar", web.Bind(forms.AvatarForm{}), admin.AvatarPost)
|
||||
m.Post("/{userid}/avatar/delete", admin.DeleteAvatar)
|
||||
m.Group("/{userid}/sessions", func() {
|
||||
m.Get("", admin.UserSessions)
|
||||
m.Post("/revoke", web.Bind(forms.RevokeSessionForm{}), admin.RevokeUserSession)
|
||||
m.Post("/revoke_all", admin.RevokeAllUserSessions)
|
||||
})
|
||||
})
|
||||
|
||||
m.Group("/emails", func() {
|
||||
|
||||
Reference in new issue
Block a user