Skip to content

Display User signin metadata in admin dashboard #33955

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions models/user/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/util"

"xorm.io/builder"
"xorm.io/xorm"
)

// Setting is a key value store of user settings
Expand Down Expand Up @@ -211,3 +212,17 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string
return err
})
}

// BuildSignupIPQuery builds a query to find users by their signup IP addresses
func BuildSignupIPQuery(ctx context.Context, keyword string) *xorm.Session {
query := db.GetEngine(ctx).
Table("user_setting").
Join("INNER", "user", "user.id = user_setting.user_id").
Where("user_setting.setting_key = ?", SignupIP)

if len(keyword) > 0 {
query = query.And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%")
}
return query
}
32 changes: 32 additions & 0 deletions modules/util/network.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package util

import (
"strings"
)

// TrimPortFromIP removes the client port from an IP address
// Handles both IPv4 and IPv6 addresses with ports
func TrimPortFromIP(ip string) string {
// Handle IPv6 with brackets: [IPv6]:port
if strings.HasPrefix(ip, "[") {
// If there's no port, return as is
if !strings.Contains(ip, "]:") {
return ip
}
// Remove the port part after ]:
return strings.Split(ip, "]:")[0] + "]"
}

// Count colons to differentiate between IPv4 and IPv6
colonCount := strings.Count(ip, ":")

// Handle IPv4 with port (single colon)
if colonCount == 1 {
return strings.Split(ip, ":")[0]
}

return ip
}
66 changes: 66 additions & 0 deletions modules/util/network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package util

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestTrimPortFromIP(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "IPv4 without port",
input: "192.168.1.1",
expected: "192.168.1.1",
},
{
name: "IPv4 with port",
input: "192.168.1.1:8080",
expected: "192.168.1.1",
},
{
name: "IPv6 without port",
input: "2001:db8::1",
expected: "2001:db8::1",
},
{
name: "IPv6 with brackets, without port",
input: "[2001:db8::1]",
expected: "[2001:db8::1]",
},
{
name: "IPv6 with brackets and port",
input: "[2001:db8::1]:8080",
expected: "[2001:db8::1]",
},
{
name: "localhost with port",
input: "localhost:8080",
expected: "localhost",
},
{
name: "Empty string",
input: "",
expected: "",
},
{
name: "Not an IP address",
input: "abc123",
expected: "abc123",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := TrimPortFromIP(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
10 changes: 10 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -3088,6 +3088,16 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled
users.list_status_filter.not_2fa_enabled = 2FA Disabled
users.details = User Details

ips.ip = IP Address
ips.user_agent = User Agent
ips.ip_manage_panel = Signup IP Management
ips.signup_metadata = Signup Metadata
ips.not_available = Signup metadata not available
ips.filter_sort.ip = Sort by IP (asc)
ips.filter_sort.ip_reverse = Sort by IP (desc)
ips.filter_sort.name = Sort by Username (asc)
ips.filter_sort.name_reverse = Sort by Username (desc)

emails.email_manage_panel = User Email Management
emails.primary = Primary
emails.activated = Activated
Expand Down
104 changes: 104 additions & 0 deletions routers/web/admin/ips.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package admin

import (
"net/http"

user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
)

const (
tplIPs templates.TplName = "admin/ips/list"
)

// IPs show all user signup IPs
func IPs(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ips.ip")
ctx.Data["PageIsAdminIPs"] = true
ctx.Data["RecordUserSignupMetadata"] = setting.RecordUserSignupMetadata

// If record user signup metadata is disabled, don't show the page
if !setting.RecordUserSignupMetadata {
ctx.Redirect(setting.AppSubURL + "/-/admin")
return
}

page := ctx.FormInt("page")
if page <= 1 {
page = 1
}

// Define the user IP result struct
type UserIPResult struct {
UID int64
Name string
FullName string
IP string
}

var (
userIPs []UserIPResult
count int64
err error
orderBy string
keyword = ctx.FormTrim("q")
sortType = ctx.FormString("sort")
)

ctx.Data["SortType"] = sortType
switch sortType {
case "ip":
orderBy = "user_setting.setting_value ASC, user.id ASC"
case "reverseip":
orderBy = "user_setting.setting_value DESC, user.id DESC"
case "username":
orderBy = "user.lower_name ASC, user.id ASC"
case "reverseusername":
orderBy = "user.lower_name DESC, user.id DESC"
default:
ctx.Data["SortType"] = "ip"
orderBy = "user_setting.setting_value ASC, user.id ASC"
}

// Get the count and user IPs for pagination
query := user_model.BuildSignupIPQuery(ctx, keyword)

count, err = query.Count(new(user_model.Setting))
if err != nil {
ctx.ServerError("Count", err)
return
}

err = user_model.BuildSignupIPQuery(ctx, keyword).
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip").
OrderBy(orderBy).
Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum).
Find(&userIPs)
if err != nil {
ctx.ServerError("Find", err)
return
}

for i := range userIPs {
// Trim the port from the IP
// FIXME: Maybe have a different helper for this?
userIPs[i].IP = util.TrimPortFromIP(userIPs[i].IP)
}

ctx.Data["UserIPs"] = userIPs
ctx.Data["Total"] = count
ctx.Data["Keyword"] = keyword

// Setup pagination
pager := context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager

ctx.HTML(http.StatusOK, tplIPs)
}
21 changes: 21 additions & 0 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/explore"
user_setting "code.gitea.io/gitea/routers/web/user/setting"
Expand Down Expand Up @@ -262,6 +263,7 @@ func ViewUser(ctx *context.Context) {
ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
ctx.Data["ShowUserSignupMetadata"] = setting.RecordUserSignupMetadata

u := prepareUserInfo(ctx)
if ctx.Written() {
Expand Down Expand Up @@ -291,6 +293,25 @@ func ViewUser(ctx *context.Context) {
ctx.Data["Emails"] = emails
ctx.Data["EmailsTotal"] = len(emails)

// If record user signup metadata is enabled, get the user's signup IP and user agent
if setting.RecordUserSignupMetadata {
signupIP, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupIP)
if err == nil && len(signupIP) > 0 {
ctx.Data["HasSignupIP"] = true
ctx.Data["SignupIP"] = util.TrimPortFromIP(signupIP)
} else {
ctx.Data["HasSignupIP"] = false
}

signupUserAgent, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupUserAgent)
if err == nil && len(signupUserAgent) > 0 {
ctx.Data["HasSignupUserAgent"] = true
ctx.Data["SignupUserAgent"] = signupUserAgent
} else {
ctx.Data["HasSignupUserAgent"] = false
}
}

orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
ListOptions: db.ListOptionsAll,
UserID: u.ID,
Expand Down
6 changes: 5 additions & 1 deletion routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,10 @@ func registerWebRoutes(m *web.Router) {
m.Post("/delete", admin.DeleteEmail)
})

m.Group("/ips", func() {
m.Get("", admin.IPs)
})

m.Group("/orgs", func() {
m.Get("", admin.Organizations)
})
Expand Down Expand Up @@ -814,7 +818,7 @@ func registerWebRoutes(m *web.Router) {
addSettingsRunnersRoutes()
addSettingsVariablesRoutes()
})
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
}, adminReq, ctxDataSet("RecordUserSignupMetadata", setting.RecordUserSignupMetadata, "EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
// ***** END: Admin *****

m.Group("", func() {
Expand Down
58 changes: 58 additions & 0 deletions templates/admin/ips/list.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.ips.ip_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
</h4>
<div class="ui attached segment">
<div class="ui secondary filter menu tw-items-center tw-mx-0">
<form class="ui form ignore-dirty tw-flex-1">
{{template "shared/search/combo" dict "Value" .Keyword}}
</form>
<!-- Sort -->
<div class="ui dropdown type jump item tw-mr-0">
<span class="text">
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
<a class="{{if or (eq .SortType "ip") (not .SortType)}}active {{end}}item" href="?sort=ip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip"}}</a>
<a class="{{if eq .SortType "reverseip"}}active {{end}}item" href="?sort=reverseip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip_reverse"}}</a>
<a class="{{if eq .SortType "username"}}active {{end}}item" href="?sort=username&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name"}}</a>
<a class="{{if eq .SortType "reverseusername"}}active {{end}}item" href="?sort=reverseusername&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name_reverse"}}</a>
</div>
</div>
</div>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th data-sortt-asc="username" data-sortt-desc="reverseusername">
{{ctx.Locale.Tr "admin.users.name"}}
{{SortArrow "username" "reverseusername" $.SortType false}}
</th>
<th>{{ctx.Locale.Tr "admin.users.full_name"}}</th>
<th data-sortt-asc="ip" data-sortt-desc="reverseip" data-sortt-default="true">
{{ctx.Locale.Tr "admin.ips.ip"}}
{{SortArrow "ip" "reverseip" $.SortType true}}
</th>
</tr>
</thead>
<tbody>
{{range .UserIPs}}
<tr>
<td><a href="{{AppSubUrl}}/-/admin/users/{{.UID}}">{{.Name}}</a></td>
<td>{{.FullName}}</td>
<td><a href="?q={{.IP}}&sort={{$.SortType}}">{{.IP}}</a></td>
</tr>
{{else}}
<tr><td class="tw-text-center" colspan="3">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}}
</tbody>
</table>
</div>

{{template "base/paginate" .}}
</div>

{{template "admin/layout_footer" .}}
7 changes: 6 additions & 1 deletion templates/admin/navbar.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</a>
</div>
</details>
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminIPs .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.identity_access"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminAuthentications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/auths">
Expand All @@ -28,6 +28,11 @@
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
{{ctx.Locale.Tr "admin.emails"}}
</a>
{{if .RecordUserSignupMetadata}}
<a class="{{if .PageIsAdminIPs}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ips">
{{ctx.Locale.Tr "admin.ips.ip"}}
</a>
{{end}}
</div>
</details>
<details class="item toggleable-item" {{if or .PageIsAdminRepositories (and .EnablePackages .PageIsAdminPackages)}}open{{end}}>
Expand Down
Loading