mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
fix(api-token): hash tokens at rest and show plaintext only once
Store API tokens as SHA-256 hashes instead of plaintext and return the token value only in the create response. List no longer exposes the token, and the UI drops the Show/Copy buttons in favor of a one-time reveal modal at creation. Match hashes the presented bearer token before the constant-time compare, and a migration hashes any pre-existing plaintext rows in place so existing tokens keep authenticating. Docs and translations updated.
This commit is contained in:
parent
7a72aeda7a
commit
4813a2fe00
21 changed files with 145 additions and 65 deletions
|
|
@ -181,7 +181,7 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if empty && isUsersEmpty {
|
if empty && isUsersEmpty {
|
||||||
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix"}
|
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
|
||||||
for _, name := range seeders {
|
for _, name := range seeders {
|
||||||
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -232,6 +232,12 @@ func runSeeders(isUsersEmpty bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(seedersHistory, "ApiTokensHash") {
|
||||||
|
if err := hashExistingApiTokens(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !slices.Contains(seedersHistory, "ClientsTable") {
|
if !slices.Contains(seedersHistory, "ClientsTable") {
|
||||||
if err := seedClientsFromInboundJSON(); err != nil {
|
if err := seedClientsFromInboundJSON(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -646,6 +652,28 @@ func seedApiTokens() error {
|
||||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hashExistingApiTokens replaces any plaintext token stored before tokens were
|
||||||
|
// hashed at rest with its SHA-256 digest. Callers keep their plaintext copy
|
||||||
|
// (used on remote nodes), so existing tokens keep authenticating; the panel
|
||||||
|
// just can no longer reveal them. Idempotent — already-hashed rows are skipped.
|
||||||
|
func hashExistingApiTokens() error {
|
||||||
|
var rows []*model.ApiToken
|
||||||
|
if err := db.Find(&rows).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if crypto.IsSHA256Hex(r.Token) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hashed := crypto.HashTokenSHA256(r.Token)
|
||||||
|
if err := db.Model(model.ApiToken{}).Where("id = ?", r.Id).Update("token", hashed).Error; err != nil {
|
||||||
|
log.Printf("Error hashing api token %d: %v", r.Id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// isTableEmpty returns true if the named table contains zero rows.
|
// isTableEmpty returns true if the named table contains zero rows.
|
||||||
func isTableEmpty(tableName string) (bool, error) {
|
func isTableEmpty(tableName string) (bool, error) {
|
||||||
var count int64
|
var count int64
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ type HistoryOfSeeders struct {
|
||||||
type ApiToken struct {
|
type ApiToken struct {
|
||||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||||
Token string `json:"token" gorm:"not null"`
|
Token string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
|
||||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "API Tokens",
|
"name": "API Tokens",
|
||||||
"description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request."
|
"description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Xray Settings",
|
"name": "Xray Settings",
|
||||||
|
|
@ -5105,7 +5105,7 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"API Tokens"
|
"API Tokens"
|
||||||
],
|
],
|
||||||
"summary": "List every API token, enabled or not.",
|
"summary": "List every API token, enabled or not. The token value is never returned — only metadata.",
|
||||||
"operationId": "get_panel_setting_apiTokens",
|
"operationId": "get_panel_setting_apiTokens",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
|
|
@ -5130,7 +5130,6 @@
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "default",
|
"name": "default",
|
||||||
"token": "abcdef-12345-...",
|
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"createdAt": 1736000000
|
"createdAt": 1736000000
|
||||||
}
|
}
|
||||||
|
|
@ -5147,7 +5146,7 @@
|
||||||
"tags": [
|
"tags": [
|
||||||
"API Tokens"
|
"API Tokens"
|
||||||
],
|
],
|
||||||
"summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.",
|
"summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.",
|
||||||
"operationId": "post_panel_setting_apiTokens_create",
|
"operationId": "post_panel_setting_apiTokens_create",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|
|
||||||
|
|
@ -951,18 +951,18 @@ export const sections: readonly Section[] = [
|
||||||
id: 'api-tokens',
|
id: 'api-tokens',
|
||||||
title: 'API Tokens',
|
title: 'API Tokens',
|
||||||
description:
|
description:
|
||||||
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/setting/apiTokens',
|
path: '/panel/setting/apiTokens',
|
||||||
summary: 'List every API token, enabled or not.',
|
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/create',
|
path: '/panel/setting/apiTokens/create',
|
||||||
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
|
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,11 @@
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-token-created-notice {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.security-actions {
|
.security-actions {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ interface ApiMsg<T = unknown> {
|
||||||
interface ApiTokenRow {
|
interface ApiTokenRow {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
token: string;
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -77,10 +76,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
|
|
||||||
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
|
const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
|
||||||
const [apiTokensLoading, setApiTokensLoading] = useState(false);
|
const [apiTokensLoading, setApiTokensLoading] = useState(false);
|
||||||
const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createName, setCreateName] = useState('');
|
const [createName, setCreateName] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
|
||||||
|
|
||||||
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
|
const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
|
||||||
setTfa({ ...opts, open: true });
|
setTfa({ ...opts, open: true });
|
||||||
|
|
@ -137,14 +136,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
loadApiTokens();
|
loadApiTokens();
|
||||||
}, [loadApiTokens]);
|
}, [loadApiTokens]);
|
||||||
|
|
||||||
function toggleTokenVisibility(id: number) {
|
|
||||||
setVisibleTokenIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyToken(token: string) {
|
async function copyToken(token: string) {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const ok = await ClipboardManager.copyText(token);
|
const ok = await ClipboardManager.copyText(token);
|
||||||
|
|
@ -165,17 +156,12 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
}
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
|
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
await loadApiTokens();
|
await loadApiTokens();
|
||||||
if (msg.obj?.id != null) {
|
if (msg.obj?.token) {
|
||||||
const id = msg.obj.id;
|
setCreatedToken({ name, token: msg.obj.token });
|
||||||
setVisibleTokenIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -206,11 +192,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskToken(token: string): string {
|
|
||||||
if (!token) return '';
|
|
||||||
return '•'.repeat(Math.min(token.length, 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokenDate(ts: number): string {
|
function formatTokenDate(ts: number): string {
|
||||||
if (!ts) return '';
|
if (!ts) return '';
|
||||||
return new Date(ts * 1000).toLocaleString();
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
|
@ -326,17 +307,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="api-token-value-wrap">
|
|
||||||
<code className="api-token-value">
|
|
||||||
{visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
|
|
||||||
</code>
|
|
||||||
<Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
|
|
||||||
{visibleTokenIds.has(row.id)
|
|
||||||
? (t('pages.settings.security.hide') || 'Hide')
|
|
||||||
: (t('pages.settings.security.show') || 'Show')}
|
|
||||||
</Button>
|
|
||||||
<Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|
@ -367,6 +337,26 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!createdToken}
|
||||||
|
title={t('pages.settings.security.apiTokenCreatedTitle') || 'Token created'}
|
||||||
|
okText={t('done')}
|
||||||
|
onOk={() => setCreatedToken(null)}
|
||||||
|
onCancel={() => setCreatedToken(null)}
|
||||||
|
cancelButtonProps={{ style: { display: 'none' } }}
|
||||||
|
>
|
||||||
|
<p className="api-token-created-notice">
|
||||||
|
{t('pages.settings.security.apiTokenCreatedNotice')
|
||||||
|
|| 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
|
||||||
|
</p>
|
||||||
|
<div className="api-token-value-wrap">
|
||||||
|
<code className="api-token-value">{createdToken?.token}</code>
|
||||||
|
<Button size="small" type="primary" onClick={() => createdToken && copyToken(createdToken.token)}>
|
||||||
|
{t('copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<TwoFactorModal
|
<TwoFactorModal
|
||||||
open={tfa.open}
|
open={tfa.open}
|
||||||
title={tfa.title}
|
title={tfa.title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -20,3 +23,25 @@ func IsHashed(s string) bool {
|
||||||
_, err := bcrypt.Cost([]byte(s))
|
_, err := bcrypt.Cost([]byte(s))
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashTokenSHA256 returns the hex-encoded SHA-256 digest of token. API tokens
|
||||||
|
// are high-entropy random strings, so a fast unsalted digest is sufficient to
|
||||||
|
// keep them irrecoverable at rest while allowing constant-time verification.
|
||||||
|
func HashTokenSHA256(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSHA256Hex reports whether s looks like a hex-encoded SHA-256 digest
|
||||||
|
// (64 lowercase hex characters), used to skip already-hashed token rows.
|
||||||
|
func IsSHA256Hex(s string) bool {
|
||||||
|
if len(s) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v3/database"
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/random"
|
"github.com/mhsanaei/3x-ui/v3/util/random"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,16 +19,18 @@ const apiTokenLength = 48
|
||||||
type ApiTokenView struct {
|
type ApiTokenView struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toView builds the metadata view returned by List. It never carries the
|
||||||
|
// token value: only a SHA-256 hash is stored, and the plaintext is shown
|
||||||
|
// exactly once at creation time.
|
||||||
func toView(t *model.ApiToken) *ApiTokenView {
|
func toView(t *model.ApiToken) *ApiTokenView {
|
||||||
return &ApiTokenView{
|
return &ApiTokenView{
|
||||||
Id: t.Id,
|
Id: t.Id,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Token: t.Token,
|
|
||||||
Enabled: t.Enabled,
|
Enabled: t.Enabled,
|
||||||
CreatedAt: t.CreatedAt,
|
CreatedAt: t.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
@ -62,15 +65,18 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, common.NewError("a token with that name already exists")
|
return nil, common.NewError("a token with that name already exists")
|
||||||
}
|
}
|
||||||
|
plaintext := random.Seq(apiTokenLength)
|
||||||
row := &model.ApiToken{
|
row := &model.ApiToken{
|
||||||
Name: name,
|
Name: name,
|
||||||
Token: random.Seq(apiTokenLength),
|
Token: crypto.HashTokenSHA256(plaintext),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}
|
}
|
||||||
if err := db.Create(row).Error; err != nil {
|
if err := db.Create(row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toView(row), nil
|
view := toView(row)
|
||||||
|
view.Token = plaintext
|
||||||
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiTokenService) Delete(id int) error {
|
func (s *ApiTokenService) Delete(id int) error {
|
||||||
|
|
@ -97,8 +103,9 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match returns true when the presented bearer token matches any enabled
|
// Match returns true when the presented bearer token matches any enabled
|
||||||
// row in api_tokens. Uses constant-time compare per row so a remote
|
// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
|
||||||
// attacker can't time-attack tokens byte-by-byte.
|
// value is hashed before a constant-time compare per row keeps a remote
|
||||||
|
// attacker from timing the comparison byte-by-byte.
|
||||||
func (s *ApiTokenService) Match(presented string) bool {
|
func (s *ApiTokenService) Match(presented string) bool {
|
||||||
if presented == "" {
|
if presented == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -108,10 +115,10 @@ func (s *ApiTokenService) Match(presented string) bool {
|
||||||
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
presentedBytes := []byte(presented)
|
presentedHash := []byte(crypto.HashTokenSHA256(presented))
|
||||||
matched := false
|
matched := false
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
|
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
|
||||||
matched = true
|
matched = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "مثل central-panel-a",
|
"apiTokenNamePlaceholder": "مثل central-panel-a",
|
||||||
"apiTokenNameRequired": "الاسم مطلوب",
|
"apiTokenNameRequired": "الاسم مطلوب",
|
||||||
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
|
"apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
|
||||||
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
|
"apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.",
|
||||||
|
"apiTokenCreatedTitle": "تم إنشاء الرمز",
|
||||||
|
"apiTokenCreatedNotice": "انسخ هذا الرمز الآن. لأسباب أمنية لا يتم تخزينه بصيغة قابلة للقراءة ولن يتم عرضه مرة أخرى."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "تم تغيير المعلمات.",
|
"modifySettings": "تم تغيير المعلمات.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "e.g. central-panel-a",
|
"apiTokenNamePlaceholder": "e.g. central-panel-a",
|
||||||
"apiTokenNameRequired": "Name is required",
|
"apiTokenNameRequired": "Name is required",
|
||||||
"apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
|
"apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
|
||||||
"apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
|
"apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately.",
|
||||||
|
"apiTokenCreatedTitle": "Token created",
|
||||||
|
"apiTokenCreatedNotice": "Copy this token now. For security it is not stored in readable form and will not be shown again."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "The parameters have been changed.",
|
"modifySettings": "The parameters have been changed.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "por ejemplo central-panel-a",
|
"apiTokenNamePlaceholder": "por ejemplo central-panel-a",
|
||||||
"apiTokenNameRequired": "El nombre es obligatorio",
|
"apiTokenNameRequired": "El nombre es obligatorio",
|
||||||
"apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
|
"apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
|
||||||
"apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
|
"apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente.",
|
||||||
|
"apiTokenCreatedTitle": "Token creado",
|
||||||
|
"apiTokenCreatedNotice": "Copia este token ahora. Por seguridad, no se almacena de forma legible y no se volverá a mostrar."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Los parámetros han sido modificados.",
|
"modifySettings": "Los parámetros han sido modificados.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
|
"apiTokenNamePlaceholder": "مثلاً central-panel-a",
|
||||||
"apiTokenNameRequired": "نام الزامی است",
|
"apiTokenNameRequired": "نام الزامی است",
|
||||||
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
|
"apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت رباتها یا پنلهای راه دور یکی بسازید.",
|
||||||
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود."
|
"apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده میکند بلافاصله احراز هویتش قطع میشود.",
|
||||||
|
"apiTokenCreatedTitle": "توکن ساخته شد",
|
||||||
|
"apiTokenCreatedNotice": "اکنون این توکن را کپی کنید. بهدلیل امنیتی بهصورت قابلخواندن ذخیره نمیشود و دوباره نمایش داده نخواهد شد."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "پارامترها تغییر کردهاند.",
|
"modifySettings": "پارامترها تغییر کردهاند.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "misalnya central-panel-a",
|
"apiTokenNamePlaceholder": "misalnya central-panel-a",
|
||||||
"apiTokenNameRequired": "Nama wajib diisi",
|
"apiTokenNameRequired": "Nama wajib diisi",
|
||||||
"apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
|
"apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
|
||||||
"apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
|
"apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera.",
|
||||||
|
"apiTokenCreatedTitle": "Token dibuat",
|
||||||
|
"apiTokenCreatedNotice": "Salin token ini sekarang. Demi keamanan, token tidak disimpan dalam bentuk yang dapat dibaca dan tidak akan ditampilkan lagi."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Parameter telah diubah.",
|
"modifySettings": "Parameter telah diubah.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "例: central-panel-a",
|
"apiTokenNamePlaceholder": "例: central-panel-a",
|
||||||
"apiTokenNameRequired": "名前は必須です",
|
"apiTokenNameRequired": "名前は必須です",
|
||||||
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
|
"apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
|
||||||
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
|
"apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。",
|
||||||
|
"apiTokenCreatedTitle": "トークンを作成しました",
|
||||||
|
"apiTokenCreatedNotice": "このトークンを今すぐコピーしてください。セキュリティ上、読み取り可能な形式では保存されず、再表示されません。"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "パラメーターが変更されました。",
|
"modifySettings": "パラメーターが変更されました。",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "ex.: central-panel-a",
|
"apiTokenNamePlaceholder": "ex.: central-panel-a",
|
||||||
"apiTokenNameRequired": "O nome é obrigatório",
|
"apiTokenNameRequired": "O nome é obrigatório",
|
||||||
"apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
|
"apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
|
||||||
"apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
|
"apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente.",
|
||||||
|
"apiTokenCreatedTitle": "Token criado",
|
||||||
|
"apiTokenCreatedNotice": "Copie este token agora. Por segurança, ele não é armazenado de forma legível e não será exibido novamente."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Os parâmetros foram alterados.",
|
"modifySettings": "Os parâmetros foram alterados.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "например, central-panel-a",
|
"apiTokenNamePlaceholder": "например, central-panel-a",
|
||||||
"apiTokenNameRequired": "Имя обязательно",
|
"apiTokenNameRequired": "Имя обязательно",
|
||||||
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
|
"apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
|
||||||
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
|
"apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.",
|
||||||
|
"apiTokenCreatedTitle": "Токен создан",
|
||||||
|
"apiTokenCreatedNotice": "Скопируйте этот токен сейчас. В целях безопасности он не хранится в читаемом виде и больше не будет показан."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Настройки изменены",
|
"modifySettings": "Настройки изменены",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "örn. central-panel-a",
|
"apiTokenNamePlaceholder": "örn. central-panel-a",
|
||||||
"apiTokenNameRequired": "Ad zorunludur",
|
"apiTokenNameRequired": "Ad zorunludur",
|
||||||
"apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
|
"apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
|
||||||
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
|
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder.",
|
||||||
|
"apiTokenCreatedTitle": "Belirteç oluşturuldu",
|
||||||
|
"apiTokenCreatedNotice": "Bu belirteci şimdi kopyalayın. Güvenlik nedeniyle okunabilir biçimde saklanmaz ve tekrar gösterilmez."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Parametreler değiştirildi.",
|
"modifySettings": "Parametreler değiştirildi.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
|
"apiTokenNamePlaceholder": "наприклад, central-panel-a",
|
||||||
"apiTokenNameRequired": "Назва обов'язкова",
|
"apiTokenNameRequired": "Назва обов'язкова",
|
||||||
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
|
"apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
|
||||||
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
|
"apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.",
|
||||||
|
"apiTokenCreatedTitle": "Токен створено",
|
||||||
|
"apiTokenCreatedNotice": "Скопіюйте цей токен зараз. З міркувань безпеки він не зберігається у читабельному вигляді й більше не відображатиметься."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Параметри було змінено.",
|
"modifySettings": "Параметри було змінено.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "ví dụ: central-panel-a",
|
"apiTokenNamePlaceholder": "ví dụ: central-panel-a",
|
||||||
"apiTokenNameRequired": "Tên là bắt buộc",
|
"apiTokenNameRequired": "Tên là bắt buộc",
|
||||||
"apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
|
"apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
|
||||||
"apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
|
"apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức.",
|
||||||
|
"apiTokenCreatedTitle": "Đã tạo token",
|
||||||
|
"apiTokenCreatedNotice": "Hãy sao chép token này ngay bây giờ. Vì lý do bảo mật, token không được lưu ở dạng đọc được và sẽ không hiển thị lại."
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "Các tham số đã được thay đổi.",
|
"modifySettings": "Các tham số đã được thay đổi.",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||||
"apiTokenNameRequired": "名称必填",
|
"apiTokenNameRequired": "名称必填",
|
||||||
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
|
"apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
|
||||||
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
|
"apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。",
|
||||||
|
"apiTokenCreatedTitle": "令牌已创建",
|
||||||
|
"apiTokenCreatedNotice": "请立即复制此令牌。出于安全考虑,它不会以可读形式存储,也不会再次显示。"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "参数已更改。",
|
"modifySettings": "参数已更改。",
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,9 @@
|
||||||
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
"apiTokenNamePlaceholder": "例如 central-panel-a",
|
||||||
"apiTokenNameRequired": "名稱必填",
|
"apiTokenNameRequired": "名稱必填",
|
||||||
"apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
|
"apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
|
||||||
"apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
|
"apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。",
|
||||||
|
"apiTokenCreatedTitle": "權杖已建立",
|
||||||
|
"apiTokenCreatedNotice": "請立即複製此權杖。基於安全考量,它不會以可讀形式儲存,也不會再次顯示。"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"modifySettings": "參數已更改。",
|
"modifySettings": "參數已更改。",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue