package main
import (
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const pbkdf2Iterations = 600_000
var usernamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
const (
providerGitHub = "github"
providerGoogle = "google"
)
type Account struct {
PasswordSalt []byte `json:"passwordSalt,omitempty"`
PasswordHash []byte `json:"passwordHash,omitempty"`
Links []Link `json:"links,omitempty"`
}
type Link struct {
Provider string `json:"provider"`
Match string `json:"match"`
Subject string `json:"subject,omitempty"`
}
type Accounts struct {
mu sync.Mutex
path string
modTime time.Time
accounts map[string]*Account
}
func LoadAccounts(path string) (*Accounts, error) {
a := &Accounts{path: path, accounts: map[string]*Account{}}
a.mu.Lock()
defer a.mu.Unlock()
if err := a.reloadLocked(); err != nil {
return nil, err
}
return a, nil
}
func (a *Accounts) reloadLocked() error {
info, err := os.Stat(a.path)
if errors.Is(err, os.ErrNotExist) {
a.accounts, a.modTime = map[string]*Account{}, time.Time{}
return nil
}
if err != nil {
return err
}
if info.ModTime().Equal(a.modTime) {
return nil
}
raw, err := os.ReadFile(a.path)
if err != nil {
return err
}
accounts := map[string]*Account{}
if err := json.Unmarshal(raw, &accounts); err != nil {
return fmt.Errorf("%s: %w", a.path, err)
}
a.accounts, a.modTime = accounts, info.ModTime()
return nil
}
func (a *Accounts) saveLocked() error {
raw, err := json.MarshalIndent(a.accounts, "", " ")
if err != nil {
return err
}
if err := writeFileAtomic(a.path, raw); err != nil {
return err
}
if info, err := os.Stat(a.path); err == nil {
a.modTime = info.ModTime()
}
return nil
}
func (a *Accounts) update(fn func(map[string]*Account) error) error {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.reloadLocked(); err != nil {
return err
}
if err := fn(a.accounts); err != nil {
return err
}
return a.saveLocked()
}
func (a *Accounts) Create(username, password string) error {
if !usernamePattern.MatchString(username) {
return errors.New("username must be 1-32 chars of a-z, 0-9, '.', '_' or '-', starting with a letter or digit")
}
acct := &Account{}
if password != "" {
if err := acct.setPassword(password); err != nil {
return err
}
}
return a.update(func(m map[string]*Account) error {
if _, ok := m[username]; ok {
return fmt.Errorf("%s already exists", username)
}
m[username] = acct
return nil
})
}
func (a *Accounts) SetPassword(username, password string) error {
return a.update(func(m map[string]*Account) error {
acct, ok := m[username]
if !ok {
return fmt.Errorf("no such user %q", username)
}
if password == "" {
acct.PasswordSalt, acct.PasswordHash = nil, nil
return nil
}
return acct.setPassword(password)
})
}
func (a *Accounts) Delete(username string) error {
return a.update(func(m map[string]*Account) error {
if _, ok := m[username]; !ok {
return fmt.Errorf("no such user %q", username)
}
delete(m, username)
return nil
})
}
func (a *Accounts) Link(username, provider, match string) error {
if provider != providerGitHub && provider != providerGoogle {
return fmt.Errorf("provider must be %q or %q", providerGitHub, providerGoogle)
}
match = strings.ToLower(strings.TrimSpace(match))
if match == "" {
return errors.New("a GitHub login or Google email is required")
}
return a.update(func(m map[string]*Account) error {
acct, ok := m[username]
if !ok {
return fmt.Errorf("no such user %q", username)
}
for name, other := range m {
for _, l := range other.Links {
if l.Provider == provider && l.Match == match {
return fmt.Errorf("%s %s is already linked to %s", provider, match, name)
}
}
}
acct.Links = append(acct.Links, Link{Provider: provider, Match: match})
return nil
})
}
func (a *Accounts) Unlink(username, provider string) error {
return a.update(func(m map[string]*Account) error {
acct, ok := m[username]
if !ok {
return fmt.Errorf("no such user %q", username)
}
kept := acct.Links[:0]
for _, l := range acct.Links {
if l.Provider != provider {
kept = append(kept, l)
}
}
acct.Links = kept
return nil
})
}
func (a *Accounts) List() ([]string, error) {
a.mu.Lock()
defer a.mu.Unlock()
if err := a.reloadLocked(); err != nil {
return nil, err
}
var lines []string
for name, acct := range a.accounts {
var how []string
if acct.PasswordHash != nil {
how = append(how, "password")
}
for _, l := range acct.Links {
how = append(how, l.Provider+":"+l.Match)
}
lines = append(lines, fmt.Sprintf("%s\t%s", name, strings.Join(how, ", ")))
}
sort.Strings(lines)
return lines, nil
}
func (a *Accounts) Exists(username string) bool {
a.mu.Lock()
defer a.mu.Unlock()
_ = a.reloadLocked()
_, ok := a.accounts[username]
return ok
}
func (a *Accounts) CheckPassword(username, password string) bool {
a.mu.Lock()
_ = a.reloadLocked()
acct, ok := a.accounts[username]
var salt, want []byte
if ok && acct.PasswordHash != nil {
salt, want = acct.PasswordSalt, acct.PasswordHash
} else {
ok = false
salt, want = make([]byte, 16), make([]byte, 32)
}
a.mu.Unlock()
got, err := hashPassword(password, salt)
return err == nil && subtle.ConstantTimeCompare(got, want) == 1 && ok
}
func (a *Accounts) FindSocial(provider, subject, match string) (string, bool) {
match = strings.ToLower(match)
var found string
_ = a.update(func(m map[string]*Account) error {
for name, acct := range m {
for i, l := range acct.Links {
if l.Provider != provider {
continue
}
if l.Subject == subject || (l.Subject == "" && l.Match == match) {
acct.Links[i].Subject = subject
found = name
return nil
}
}
}
return errNotFound
})
return found, found != ""
}
var errNotFound = errors.New("not found")
func (acct *Account) setPassword(password string) error {
if len(password) < 8 {
return errors.New("password must be at least 8 characters")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return err
}
hash, err := hashPassword(password, salt)
if err != nil {
return err
}
acct.PasswordSalt, acct.PasswordHash = salt, hash
return nil
}
func hashPassword(password string, salt []byte) ([]byte, error) {
return pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, 32)
}
func writeFileAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}