Files
vibesonic/internal/subsonic/auth.go
T

129 lines
3.3 KiB
Go

package subsonic
import (
"crypto/md5"
"database/sql"
"encoding/hex"
"strconv"
"strings"
vsauth "git.noroutes.net/NoRoutes/vibetunes/internal/auth"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// authenticate implements original Subsonic authentication: either
// token/salt (t = md5(password + salt), the modern recommended mode) or
// password (p=<plaintext> or p=enc:<hex>, kept for older clients).
//
// This never touches VibeSonic's Argon2id-hashed password directly for
// token auth — that hash is one-way by design. Token auth is instead
// verified against a separately maintained, AES-GCM-encrypted copy of
// the password (see internal/auth/subsonic_secret.go); the /rest/*
// surface never reintroduces MD5 or plaintext as VibeSonic's own storage
// format, it only speaks it on the wire where the protocol requires it.
func authenticate(db *sql.DB, encryptionKey []byte, p params) (*database.User, *apiError) {
username, aerr := p.Required("u")
if aerr != nil {
return nil, aerr
}
user, err := database.GetUserByUsername(db, username)
if err != nil || user.Disabled {
return nil, errWrongCredentials
}
token := p.Get("t")
salt := p.Get("s")
password := p.Get("p")
switch {
case token != "" && salt != "":
if !user.SubsonicAuthSecret.Valid {
return nil, errTokenAuthNotSupported
}
plaintext, derr := vsauth.DecryptPassword(encryptionKey, user.SubsonicAuthSecret.String)
if derr != nil {
return nil, errWrongCredentials
}
sum := md5.Sum([]byte(plaintext + salt))
expected := hex.EncodeToString(sum[:])
if !strings.EqualFold(expected, token) {
return nil, errWrongCredentials
}
case password != "":
plaintext := password
if strings.HasPrefix(password, "enc:") {
decoded, herr := hex.DecodeString(strings.TrimPrefix(password, "enc:"))
if herr != nil {
return nil, errWrongCredentials
}
plaintext = string(decoded)
}
if !user.PasswordHash.Valid {
return nil, errWrongCredentials
}
ok, verr := vsauth.VerifyPassword(plaintext, user.PasswordHash.String)
if verr != nil || !ok {
return nil, errWrongCredentials
}
default:
return nil, errMissingParameter("p or t/s")
}
return user, nil
}
// checkVersion rejects clients whose declared protocol version is newer
// than what this server implements. A missing or unparsable v is
// tolerated (treated as compatible) rather than rejected outright, to
// stay lenient with tooling that omits it — the protocol is additive, so
// there is no reason to reject an old client either.
func checkVersion(v string) *apiError {
if v == "" {
return nil
}
client, ok := parseVersion(v)
if !ok {
return nil
}
server, _ := parseVersion(ServerVersion)
if versionGreater(client, server) {
return errClientMustUpgrade
}
return nil
}
type semver struct{ major, minor, patch int }
func parseVersion(v string) (semver, bool) {
parts := strings.SplitN(v, ".", 3)
if len(parts) == 0 {
return semver{}, false
}
var s semver
var err error
s.major, err = strconv.Atoi(parts[0])
if err != nil {
return semver{}, false
}
if len(parts) > 1 {
s.minor, _ = strconv.Atoi(parts[1])
}
if len(parts) > 2 {
s.patch, _ = strconv.Atoi(parts[2])
}
return s, true
}
func versionGreater(a, b semver) bool {
if a.major != b.major {
return a.major > b.major
}
if a.minor != b.minor {
return a.minor > b.minor
}
return a.patch > b.patch
}