Phase 1b/1c: server-side input handling and authentication
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/push Build encountered an error
Input handling: - uploadLogo: client file name reduced to a safe base name with an image extension (path traversal and arbitrary-extension writes closed). - extractZIP: zip-slip guard; entries that resolve outside the target are refused. Per-entry closes no longer pile up as defers. - ffmpeg.path / vlc.path must be an existing regular file named ffmpeg, vlc or cvlc, checked both when saved and right before exec. - Stream URLs passed to the external buffer must use a network scheme (http, https, rtsp, rtmp, rtp, udp, mms); file:, concat:, pipe: are refused. - /download/ (backups with settings.json and authentication.json) requires the web session when web authentication is enabled. - settings.json is written 0600; the Plex token is masked in every payload sent to the UI and the mask round-trips as "unchanged" on save. Authentication: - Passwords are stored with bcrypt. Existing HMAC-SHA256 records still verify (constant time) and are re-hashed on the first successful login. Username lookups compare in constant time. - URL (?username=&password=) and HTTP Basic authentication verify the credentials per request via AuthenticateUser and no longer create a session token, which removes the unbounded token growth under Plex polling. Expired sessions are evicted whenever a new one is created. - createFirstUserForAuthentication and checkAuthorizationLevel now return real errors instead of calling no-op closures. Tests: src/security_test.go and src/internal/authentication/ authentication_test.go cover each of the above.
This commit is contained in:
1 parent
4976219857
commit
36303fecea
14 files changed
+708
-114
No files matched your search
@@ -1,8 +1,11 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -103,7 +106,7 @@ func CreateNewUser(username, password string) (userID string, err error) {
|
||||
var salt = userData["_salt"].(string)
|
||||
var loginUsername = userData["_username"].(string)
|
||||
|
||||
if SHA256(username, salt) == loginUsername {
|
||||
if hmac.Equal([]byte(SHA256(username, salt)), []byte(loginUsername)) {
|
||||
err = createError(020)
|
||||
}
|
||||
|
||||
@@ -127,39 +130,61 @@ func CreateNewUser(username, password string) (userID string, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// UserAuthentication : user authentication
|
||||
func UserAuthentication(username, password string) (token string, err error) {
|
||||
// AuthenticateUser : verifies a username/password pair and returns the user
|
||||
// ID. No session token is created; use UserAuthentication for that.
|
||||
func AuthenticateUser(username, password string) (userID string, err error) {
|
||||
|
||||
err = checkInit()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var login = func(username, password string, loginData map[string]any) (err error) {
|
||||
err = createError(010)
|
||||
err = createError(010)
|
||||
|
||||
var salt = loginData["_salt"].(string)
|
||||
var loginUsername = loginData["_username"].(string)
|
||||
var loginPassword = loginData["_password"].(string)
|
||||
var users = data["users"].(map[string]any)
|
||||
for id, v := range users {
|
||||
|
||||
if SHA256(username, salt) == loginUsername {
|
||||
if SHA256(password, salt) == loginPassword {
|
||||
err = nil
|
||||
loginData, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var salt, _ = loginData["_salt"].(string)
|
||||
var storedUsername, _ = loginData["_username"].(string)
|
||||
var storedPassword, _ = loginData["_password"].(string)
|
||||
|
||||
if !hmac.Equal([]byte(SHA256(username, salt)), []byte(storedUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ok, upgrade := verifyPassword(storedPassword, password, salt)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy SHA256 record: re-hash with bcrypt now that the password is known.
|
||||
if upgrade {
|
||||
loginData["_password"] = hashPassword(password)
|
||||
if saveErr := saveDatabase(data); saveErr != nil {
|
||||
return "", saveErr
|
||||
}
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// UserAuthentication : login; returns a new session token on success.
|
||||
func UserAuthentication(username, password string) (token string, err error) {
|
||||
|
||||
userID, err := AuthenticateUser(username, password)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var users = data["users"].(map[string]any)
|
||||
for id, loginData := range users {
|
||||
err = login(username, password, loginData.(map[string]any))
|
||||
if err == nil {
|
||||
token = setToken(id, "-")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token = setToken(userID, "-")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,7 +330,7 @@ func SetDefaultUserData(defaults map[string]any) (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// ChangeCredentials : change credentials
|
||||
// ChangeCredentials : change username and/or password of a user
|
||||
func ChangeCredentials(userID, username, password string) (err error) {
|
||||
err = checkInit()
|
||||
if err != nil {
|
||||
@@ -315,7 +340,6 @@ func ChangeCredentials(userID, username, password string) (err error) {
|
||||
err = createError(032)
|
||||
|
||||
if userData, ok := data["users"].(map[string]any)[userID]; ok {
|
||||
//var userData = tmp.(map[string]interface{})
|
||||
var salt = userData.(map[string]any)["_salt"].(string)
|
||||
|
||||
if len(username) > 0 {
|
||||
@@ -323,7 +347,7 @@ func ChangeCredentials(userID, username, password string) (err error) {
|
||||
}
|
||||
|
||||
if len(password) > 0 {
|
||||
userData.(map[string]any)["_password"] = SHA256(password, salt)
|
||||
userData.(map[string]any)["_password"] = hashPassword(password)
|
||||
}
|
||||
|
||||
err = saveDatabase(data)
|
||||
@@ -409,7 +433,35 @@ func loadDatabase() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// SHA256 : password + salt = sha256 string
|
||||
// hashPassword : bcrypt hash for storage.
|
||||
func hashPassword(password string) string {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
// Only reachable with an absurd cost or a >72 byte password; refuse
|
||||
// to store something that cannot be verified.
|
||||
return "!" + randomString(16)
|
||||
}
|
||||
return string(hash)
|
||||
}
|
||||
|
||||
// verifyPassword : checks password against a stored hash. Legacy records hold
|
||||
// the old unsalted HMAC-SHA256; those verify in constant time and are reported
|
||||
// as needing an upgrade to bcrypt.
|
||||
func verifyPassword(stored, password, salt string) (ok bool, upgrade bool) {
|
||||
|
||||
if strings.HasPrefix(stored, "$2") {
|
||||
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil, false
|
||||
}
|
||||
|
||||
if hmac.Equal([]byte(SHA256(password, salt)), []byte(stored)) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
// SHA256 : legacy hash (HMAC-SHA256 keyed by the secret). Still used for the
|
||||
// username lookup key and for verifying old password records once.
|
||||
func SHA256(secret, salt string) string {
|
||||
key := []byte(secret)
|
||||
h := hmac.New(sha256.New, key)
|
||||
@@ -470,15 +522,15 @@ func defaultsForNewUser(username, password string) map[string]any {
|
||||
var defaults = make(map[string]any)
|
||||
var salt = randomString(saltLength)
|
||||
defaults["_username"] = SHA256(username, salt)
|
||||
defaults["_password"] = SHA256(password, salt)
|
||||
defaults["_password"] = hashPassword(password)
|
||||
defaults["_salt"] = salt
|
||||
defaults["_id"] = "id-" + randomID(idLength)
|
||||
//defaults["_one.time.token"] = randomString(tokenLength)
|
||||
defaults["data"] = make(map[string]any)
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
// setToken : creates a session token for a user and drops expired ones.
|
||||
func setToken(id, oldToken string) (newToken string) {
|
||||
tokensMu.Lock()
|
||||
defer tokensMu.Unlock()
|
||||
@@ -487,10 +539,13 @@ func setToken(id, oldToken string) (newToken string) {
|
||||
delete(tokens, oldToken)
|
||||
}
|
||||
|
||||
loopToken:
|
||||
newToken = randomString(tokenLength)
|
||||
if _, ok := tokens[newToken]; ok {
|
||||
goto loopToken
|
||||
evictExpiredTokensLocked()
|
||||
|
||||
for {
|
||||
newToken = randomString(tokenLength)
|
||||
if _, ok := tokens[newToken]; !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var tmp = make(map[string]any)
|
||||
@@ -502,6 +557,18 @@ loopToken:
|
||||
return
|
||||
}
|
||||
|
||||
// evictExpiredTokensLocked : removes expired sessions. Caller holds tokensMu.
|
||||
func evictExpiredTokensLocked() {
|
||||
var now = time.Now().Local()
|
||||
for k, v := range tokens {
|
||||
if entry, ok := v.(map[string]any); ok {
|
||||
if expires, ok := entry["expires"].(time.Time); ok && expires.Before(now) {
|
||||
delete(tokens, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetCookieToken : set cookie
|
||||
// SetCookieToken : sets the session cookie. It is HttpOnly (scripts cannot
|
||||
// read it), SameSite=Strict, and a session cookie: expiry is enforced server
|
||||
|
||||
Reference in new issue
Block a user