evcc-io/server/remote/clients.go
2026-04-13 20:21:33 +00:00

166 lines
4.1 KiB
Go

package remote
import (
"crypto/rand"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/samber/lo"
"github.com/sethvargo/go-password/password"
"golang.org/x/crypto/bcrypt"
)
// dummyHash is a bcrypt hash of a random value, used to make the
// "unknown user" path take the same time as a real password check and
// prevent username enumeration via timing side channels.
var dummyHash []byte
func init() {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
h, err := bcrypt.GenerateFromPassword(buf, bcrypt.DefaultCost)
if err != nil {
panic(err)
}
dummyHash = h
}
// Client is a single tunnel basic-auth credential used by a remote client.
type Client struct {
Username string `json:"username"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
type persistedClient struct {
Client
Hash string `json:"hash"`
}
// loadClients reads the persisted client list.
func loadClients() []persistedClient {
var res []persistedClient
_ = settings.Json(keys.RemoteClients, &res)
return res
}
// saveClients persists the given client list.
func saveClients(list []persistedClient) error {
return settings.SetJson(keys.RemoteClients, list)
}
// generatePassword returns a crypto-random alphanumeric password
// with 20 characters including 4 digits (~96 bits of entropy).
func generatePassword() (string, error) {
return password.Generate(20, 4, 0, false, false)
}
// Clients returns the list of configured clients (without password hashes).
func (r *Remote) Clients() []Client {
return lo.Map(loadClients(), func(c persistedClient, _ int) Client {
return c.Client
})
}
// CreateClient creates a new client with an auto-generated password.
// expiresIn <= 0 means the client never expires.
// Returns the cleartext password (shown to the user only once).
func (r *Remote) CreateClient(username string, expiresIn time.Duration) (Client, string, error) {
username = strings.TrimSpace(username)
if username == "" {
return Client{}, "", errors.New("username required")
}
// RFC 7617: ":" is the basic-auth separator; reject control chars too.
for _, r := range username {
if r == ':' || r < 0x20 || r == 0x7f {
return Client{}, "", errors.New("username contains invalid characters")
}
}
var expires *time.Time
if expiresIn > 0 {
expires = new(time.Now().Add(expiresIn))
}
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
for _, c := range list {
if c.Username == username {
return Client{}, "", fmt.Errorf("client %q already exists", username)
}
}
password, err := generatePassword()
if err != nil {
return Client{}, "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return Client{}, "", err
}
c := persistedClient{
Client: Client{
Username: username,
CreatedAt: time.Now(),
ExpiresAt: expires,
},
Hash: string(hash),
}
list = append(list, c)
if err := saveClients(list); err != nil {
return Client{}, "", err
}
return c.Client, password, nil
}
// DeleteClient removes a client by username.
func (r *Remote) DeleteClient(username string) error {
r.mu.Lock()
defer r.mu.Unlock()
list := loadClients()
idx := slices.IndexFunc(list, func(c persistedClient) bool {
return c.Username == username
})
if idx == -1 {
return fmt.Errorf("client %s not found", username)
}
return saveClients(slices.Delete(list, idx, idx+1))
}
// Authenticate validates basic-auth credentials. Always runs bcrypt
// (against a dummy hash on miss) to prevent username enumeration via timing.
func (r *Remote) Authenticate(username, password string) bool {
hash := dummyHash
var found *persistedClient
for _, c := range loadClients() {
if c.Username == username {
found = &c
hash = []byte(c.Hash)
break
}
}
valid := bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
if !valid || found == nil {
return false
}
if found.ExpiresAt != nil && time.Now().After(*found.ExpiresAt) {
return false
}
return true
}