166 lines
4.1 KiB
Go
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
|
|
}
|