Files
gitea-tea/modules/config/login.go
Michal Suchanek 59656dfcd2 Require non-empty token in GetLoginByToken (#895)
Fixes: #893
Reviewed-on: https://gitea.com/gitea/tea/pulls/895
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: Michal Suchanek <msuchanek@suse.de>
Co-committed-by: Michal Suchanek <msuchanek@suse.de>
2026-02-08 18:11:54 +00:00

396 lines
10 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"code.gitea.io/tea/modules/debug"
"code.gitea.io/tea/modules/httputil"
"code.gitea.io/tea/modules/theme"
"code.gitea.io/tea/modules/utils"
"github.com/charmbracelet/huh"
"golang.org/x/oauth2"
)
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
// This is used by config.Login.Client() for automatic token refresh.
const TokenRefreshThreshold = 5 * time.Minute
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"`
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
SSHAgent bool `yaml:"ssh_agent"`
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
SSHPassphrase string `yaml:"-"`
VersionCheck bool `yaml:"version_check"`
// User is username from gitea
User string `yaml:"user"`
// Created is auto created unix timestamp
Created int64 `yaml:"created"`
// RefreshToken is used to renew the access token when it expires
RefreshToken string `yaml:"refresh_token"`
// TokenExpiry is when the token expires (unix timestamp)
TokenExpiry int64 `yaml:"token_expiry"`
}
// GetLogins return all login available by config
func GetLogins() ([]Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
return config.Logins, nil
}
// GetDefaultLogin return the default login
func GetDefaultLogin() (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
if len(config.Logins) == 0 {
return nil, errors.New("No available login")
}
for _, l := range config.Logins {
if l.Default {
return &l, nil
}
}
return &config.Logins[0], nil
}
// SetDefaultLogin set the default login by name (case insensitive)
func SetDefaultLogin(name string) error {
return withConfigLock(func() error {
loginExist := false
for i := range config.Logins {
config.Logins[i].Default = false
if strings.EqualFold(config.Logins[i].Name, name) {
config.Logins[i].Default = true
loginExist = true
}
}
if !loginExist {
return fmt.Errorf("login '%s' not found", name)
}
return saveConfigUnsafe()
})
}
// GetLoginByName get login by name (case insensitive)
func GetLoginByName(name string) *Login {
err := loadConfig()
if err != nil {
log.Fatal(err)
}
for _, l := range config.Logins {
if strings.EqualFold(l.Name, name) {
return &l
}
}
return nil
}
// GetLoginByToken get login by token
func GetLoginByToken(token string) *Login {
if token == "" {
return nil
}
err := loadConfig()
if err != nil {
log.Fatal(err)
}
for _, l := range config.Logins {
if l.Token == token {
return &l
}
}
return nil
}
// GetLoginByHost finds a login by it's server URL
func GetLoginByHost(host string) *Login {
logins := GetLoginsByHost(host)
if len(logins) > 0 {
return logins[0]
}
return nil
}
// GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) []*Login {
err := loadConfig()
if err != nil {
log.Fatal(err)
}
var matches []*Login
for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil {
log.Fatal(err)
}
if loginURL.Host == host {
matches = append(matches, &config.Logins[i])
}
}
return matches
}
// DeleteLogin delete a login by name from config
func DeleteLogin(name string) error {
return withConfigLock(func() error {
idx := -1
for i, l := range config.Logins {
if strings.EqualFold(l.Name, name) {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("can not delete login '%s', does not exist", name)
}
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
return saveConfigUnsafe()
})
}
// AddLogin save a login to config
func AddLogin(login *Login) error {
return withConfigLock(func() error {
// Check for duplicate login names
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
// save login to global var
config.Logins = append(config.Logins, *login)
// save login to config file
return saveConfigUnsafe()
})
}
// SaveLoginTokens updates the token fields for an existing login.
// This is used after browser-based re-authentication to save new tokens.
func SaveLoginTokens(login *Login) error {
return withConfigLock(func() error {
for i, l := range config.Logins {
if strings.EqualFold(l.Name, login.Name) {
config.Logins[i].Token = login.Token
config.Logins[i].RefreshToken = login.RefreshToken
config.Logins[i].TokenExpiry = login.TokenExpiry
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", login.Name)
})
}
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
// Returns nil without doing anything if no refresh is needed.
func (l *Login) RefreshOAuthTokenIfNeeded() error {
if l.RefreshToken == "" || l.TokenExpiry == 0 {
return nil
}
expiryTime := time.Unix(l.TokenExpiry, 0)
if time.Now().Add(TokenRefreshThreshold).After(expiryTime) {
return l.RefreshOAuthToken()
}
return nil
}
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
// It updates the login with new token information and saves it to config.
// Uses double-checked locking to avoid unnecessary refresh calls when multiple
// processes race to refresh the same token.
func (l *Login) RefreshOAuthToken() error {
if l.RefreshToken == "" {
return fmt.Errorf("no refresh token available")
}
return withConfigLock(func() error {
// Double-check: after acquiring lock, re-read config and check if
// another process already refreshed the token
for i, login := range config.Logins {
if login.Name == l.Name {
// Check if token was refreshed by another process
if login.TokenExpiry != l.TokenExpiry && login.TokenExpiry > 0 {
expiryTime := time.Unix(login.TokenExpiry, 0)
if time.Now().Add(TokenRefreshThreshold).Before(expiryTime) {
// Token was refreshed by another process, update our copy
l.Token = login.Token
l.RefreshToken = login.RefreshToken
l.TokenExpiry = login.TokenExpiry
return nil
}
}
// Still need to refresh - proceed with OAuth call
newToken, err := doOAuthRefresh(l)
if err != nil {
return err
}
// Update login with new token information
l.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
l.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
l.TokenExpiry = newToken.Expiry.Unix()
}
// Update in config slice and save
config.Logins[i] = *l
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", l.Name)
})
}
// doOAuthRefresh performs the actual OAuth token refresh API call.
func doOAuthRefresh(l *Login) (*oauth2.Token, error) {
currentToken := &oauth2.Token{
AccessToken: l.Token,
RefreshToken: l.RefreshToken,
Expiry: time.Unix(l.TokenExpiry, 0),
}
ctx := context.Background()
httpClient := &http.Client{
Transport: httputil.WrapTransport(&http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: l.Insecure},
}),
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
oauth2Config := &oauth2.Config{
ClientID: DefaultClientID,
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
},
}
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
return newToken, nil
}
// Client returns a client to operate Gitea API. You may provide additional modifiers
// for the client like gitea.SetBasicAuth() for customization
func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client {
// Refresh OAuth token if expired or near expiry
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
}
httpClient := &http.Client{}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil)
httpClient = &http.Client{
Jar: cookieJar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
// versioncheck must be prepended in options to make sure we don't hit any version checks in the sdk
if !l.VersionCheck {
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
}
options = append(options, gitea.SetToken(l.Token), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
if debug.IsDebug() {
options = append(options, gitea.SetDebugMode())
}
if l.SSHCertPrincipal != "" {
l.askForSSHPassphrase()
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
}
if l.SSHKeyFingerprint != "" {
l.askForSSHPassphrase()
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase))
}
client, err := gitea.NewClient(l.URL, options...)
if err != nil {
var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) {
log.Fatal(err)
}
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
}
return client
}
func (l *Login) askForSSHPassphrase() {
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
if err := huh.NewInput().
Title("ssh-key is encrypted please enter the passphrase: ").
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
Value(&l.SSHPassphrase).
WithTheme(theme.GetTheme()).
Run(); err != nil {
log.Fatal(err)
}
}
}
// GetSSHHost returns SSH host name
func (l *Login) GetSSHHost() string {
if l.SSHHost != "" {
return l.SSHHost
}
u, err := url.Parse(l.URL)
if err != nil {
return ""
}
return u.Host
}