Files
gitea/modules/setting/recovery_token.go
T
petru 471cfdd161
release-nightly / nightly-binary (push) Has been cancelled
release-nightly / nightly-container (push) Has been cancelled
Modified - [install] [backup] [database] [recovery] Consolidated database backup and installer recovery support.
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring.
- 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken.
- 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state.
- 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete.
- 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops.
- 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior.

Co-Authored-By: petru @ codex (GPT-5) <codex@openai.com>
(cherry picked from commit 9879caf2292691b0cb521d12e6fee924b066bae2)
2026-06-01 03:56:03 +03:00

153 lines
4.1 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"time"
"code.gitea.io/gitea/modules/generate"
"github.com/golang-jwt/jwt/v5"
)
// start edit/add - by petru @ codex
var (
ErrRecoveryTokenInvalid = errors.New("invalid recovery token")
ErrRecoveryTokenUsed = errors.New("recovery token already used")
ErrRecoveryTokenMisconfigured = errors.New("recovery token configuration is incomplete")
)
const (
recoveryTokenTypeAccess = "access"
recoveryTokenTypeUnlock = "unlock"
)
type RecoveryTokenClaims struct {
Email string `json:"email"`
Type string `json:"type"`
jwt.RegisteredClaims
}
func IssueRecoveryToken(data *RecoveryConfig, email string, now time.Time) (string, error) {
return issueRecoveryToken(data, email, now, recoveryTokenTypeAccess)
}
func IssueRecoveryUnlockToken(data *RecoveryConfig, email string, now time.Time) (string, error) {
return issueRecoveryToken(data, email, now, recoveryTokenTypeUnlock)
}
func issueRecoveryToken(data *RecoveryConfig, email string, now time.Time, tokenType string) (string, error) {
if data == nil || strings.TrimSpace(data.TokenSecret) == "" {
return "", ErrRecoveryTokenMisconfigured
}
tokenID, err := generate.NewSecretKey()
if err != nil {
return "", err
}
claims := RecoveryTokenClaims{
Email: strings.TrimSpace(email),
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(data.EffectiveTokenTTL())),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ID: tokenID,
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(data.TokenSecret))
}
func ValidateRecoveryToken(data *RecoveryConfig, token string, now time.Time) (*RecoveryTokenClaims, error) {
return validateRecoveryToken(data, token, now, recoveryTokenTypeAccess)
}
func ValidateRecoveryUnlockToken(data *RecoveryConfig, token string, now time.Time) (*RecoveryTokenClaims, error) {
return validateRecoveryToken(data, token, now, recoveryTokenTypeUnlock)
}
func validateRecoveryToken(data *RecoveryConfig, token string, now time.Time, expectedType string) (*RecoveryTokenClaims, error) {
if data == nil || strings.TrimSpace(data.TokenSecret) == "" {
return nil, ErrRecoveryTokenMisconfigured
}
parsedToken, err := jwt.ParseWithClaims(token, &RecoveryTokenClaims{}, func(_ *jwt.Token) (any, error) {
return []byte(data.TokenSecret), nil
}, jwt.WithTimeFunc(func() time.Time {
return now
}))
if err != nil {
return nil, ErrRecoveryTokenInvalid
}
claims, ok := parsedToken.Claims.(*RecoveryTokenClaims)
if !ok || !parsedToken.Valid || strings.TrimSpace(claims.Email) == "" || strings.TrimSpace(claims.ID) == "" || claims.Type != expectedType {
return nil, ErrRecoveryTokenInvalid
}
return claims, nil
}
func ConsumeRecoveryToken(tokenID string) error {
tokenID = strings.TrimSpace(tokenID)
if tokenID == "" {
return ErrRecoveryTokenInvalid
}
usedPath := RecoveryUsedTokensPath()
if usedPath == "" {
return errors.New("setting.CustomConf is not set")
}
if err := os.MkdirAll(filepath.Dir(usedPath), 0o755); err != nil {
return err
}
tokenHash := hashRecoveryTokenID(tokenID)
usedHashes, err := loadRecoveryTokenHashes(usedPath)
if err != nil {
return err
}
if slices.Contains(usedHashes, tokenHash) {
return ErrRecoveryTokenUsed
}
usedHashes = append(usedHashes, tokenHash)
return os.WriteFile(usedPath, []byte(strings.Join(usedHashes, "\n")+"\n"), 0o600)
}
func loadRecoveryTokenHashes(usedPath string) ([]string, error) {
data, err := os.ReadFile(usedPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
lines := strings.Split(string(data), "\n")
hashes := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
hashes = append(hashes, line)
}
}
return hashes, nil
}
func hashRecoveryTokenID(tokenID string) string {
hash := sha256.Sum256([]byte(tokenID))
return hex.EncodeToString(hash[:])
}
// end edit/add - by petru @ codex