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:
MHSanaei
2026-06-03 22:57:50 +02:00
parent 7a72aeda7a
commit 4813a2fe00
21 changed files with 145 additions and 65 deletions

View File

@@ -181,7 +181,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
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 err := seedClientsFromInboundJSON(); err != nil {
return err
@@ -646,6 +652,28 @@ func seedApiTokens() 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.
func isTableEmpty(tableName string) (bool, error) {
var count int64

View File

@@ -138,7 +138,7 @@ type HistoryOfSeeders struct {
type ApiToken struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
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"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
}