- http.Server with ReadHeaderTimeout/IdleTimeout (no read/write timeouts: /stream/ is long-lived) - shared outbound clients: providerHTTPClient (5m), apiHTTPClient (30s), imgcache client (30s) - imgcache: download outside the lock, per-item helper, cache URL uses the file name not the fs path - writeFileAtomic (temp + fsync + rename) for settings/xepg/pms/urls/authentication JSON - one package-level logMu for WebScreenLog and notifications; ring buffer keeps the newest lines - notifications evict the oldest instead of random map entries - xepg XMLTV file removal rebuilt after the loop; data.go range-mutation removed - API handler returns after error body; WS request/response fresh per command - checked type assertions in data/backup/provider/screen - SIGINT/SIGTERM handled in main via src.Shutdown(); fatal paths exit 1
627 lines
13 KiB
Go
Executable File
627 lines
13 KiB
Go
Executable File
package authentication
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"encoding/json"
|
|
"errors"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
|
|
"time"
|
|
//"fmt"
|
|
//"log"
|
|
)
|
|
|
|
const tokenLength = 40
|
|
const saltLength = 20
|
|
const idLength = 10
|
|
|
|
var tokenValidity int
|
|
var database string
|
|
|
|
var databaseFile = "authentication.json"
|
|
|
|
var data = make(map[string]any)
|
|
var tokens = make(map[string]any)
|
|
var tokensMu sync.RWMutex
|
|
|
|
var initAuthentication = false
|
|
|
|
// Cookie : cookie
|
|
type Cookie struct {
|
|
Name string
|
|
Value string
|
|
Path string
|
|
Domain string
|
|
Expires time.Time
|
|
RawExpires string
|
|
}
|
|
|
|
// Init : databasePath = Path to authentication.json
|
|
func Init(databasePath string, validity int) (err error) {
|
|
database = filepath.Dir(databasePath) + string(os.PathSeparator) + databaseFile
|
|
|
|
// Check if the database already exists
|
|
if _, err = os.Stat(database); os.IsNotExist(err) {
|
|
// Create an empty database
|
|
var defaults = make(map[string]any)
|
|
defaults["dbVersion"] = "1.0"
|
|
defaults["hash"] = "sha256"
|
|
defaults["users"] = make(map[string]any)
|
|
|
|
if saveDatabase(defaults) != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Loading the database
|
|
err = loadDatabase()
|
|
|
|
// Set Token Validity
|
|
tokenValidity = validity
|
|
initAuthentication = true
|
|
return
|
|
}
|
|
|
|
// CreateDefaultUser = created efault user
|
|
func CreateDefaultUser(username, password string) (err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var users = data["users"].(map[string]any)
|
|
// Check if the default user exists
|
|
if len(users) > 0 {
|
|
err = createError(001)
|
|
return
|
|
}
|
|
|
|
var defaults = defaultsForNewUser(username, password)
|
|
users[defaults["_id"].(string)] = defaults
|
|
saveDatabase(data)
|
|
|
|
return
|
|
}
|
|
|
|
// CreateNewUser : create new user
|
|
func CreateNewUser(username, password string) (userID string, err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var checkIfTheUserAlreadyExists = func(username string, userData map[string]any) (err error) {
|
|
var salt = userData["_salt"].(string)
|
|
var loginUsername = userData["_username"].(string)
|
|
|
|
if hmac.Equal([]byte(SHA256(username, salt)), []byte(loginUsername)) {
|
|
err = createError(020)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
var users = data["users"].(map[string]any)
|
|
for _, userData := range users {
|
|
err = checkIfTheUserAlreadyExists(username, userData.(map[string]any))
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
var defaults = defaultsForNewUser(username, password)
|
|
userID = defaults["_id"].(string)
|
|
users[userID] = defaults
|
|
|
|
saveDatabase(data)
|
|
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
err = createError(010)
|
|
|
|
var users = data["users"].(map[string]any)
|
|
for id, v := range users {
|
|
|
|
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
|
|
}
|
|
|
|
token = setToken(userID, "-")
|
|
return
|
|
}
|
|
|
|
// CheckTheValidityOfTheToken : check token
|
|
func CheckTheValidityOfTheToken(token string) (newToken string, err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(011)
|
|
|
|
tokensMu.Lock()
|
|
defer tokensMu.Unlock()
|
|
|
|
if v, ok := tokens[token]; ok {
|
|
expires := v.(map[string]any)["expires"].(time.Time)
|
|
|
|
if expires.Sub(time.Now().Local()) < 0 {
|
|
delete(tokens, token)
|
|
return
|
|
}
|
|
|
|
// Keep a stable token per session and only refresh expiration.
|
|
v.(map[string]any)["expires"] = time.Now().Local().Add(time.Minute * time.Duration(tokenValidity))
|
|
newToken = token
|
|
err = nil
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GetUserID : get user ID
|
|
func GetUserID(token string) (userID string, err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(002)
|
|
|
|
tokensMu.Lock()
|
|
defer tokensMu.Unlock()
|
|
|
|
if v, ok := tokens[token]; ok {
|
|
expires := v.(map[string]any)["expires"].(time.Time)
|
|
userID = v.(map[string]any)["id"].(string)
|
|
|
|
if expires.Sub(time.Now().Local()) < 0 {
|
|
delete(tokens, token)
|
|
return
|
|
}
|
|
|
|
err = nil
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// WriteUserData : save user date
|
|
func WriteUserData(userID string, userData map[string]any) (err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(030)
|
|
|
|
if v, ok := data["users"].(map[string]any)[userID].(map[string]any); ok {
|
|
|
|
v["data"] = userData
|
|
err = saveDatabase(data)
|
|
|
|
} else {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// ReadUserData : load user date
|
|
func ReadUserData(userID string) (userData map[string]any, err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(031)
|
|
|
|
if v, ok := data["users"].(map[string]any)[userID].(map[string]any); ok {
|
|
userData = v["data"].(map[string]any)
|
|
err = nil
|
|
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// RemoveUser : remove user
|
|
func RemoveUser(userID string) (err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(032)
|
|
|
|
if _, ok := data["users"].(map[string]any)[userID]; ok {
|
|
|
|
delete(data["users"].(map[string]any), userID)
|
|
err = saveDatabase(data)
|
|
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// SetDefaultUserData : set default user data
|
|
func SetDefaultUserData(defaults map[string]any) (err error) {
|
|
|
|
allUserData, err := GetAllUserData()
|
|
|
|
for _, d := range allUserData {
|
|
var data = d.(map[string]any)["data"].(map[string]any)
|
|
var userID = d.(map[string]any)["_id"].(string)
|
|
|
|
for k, v := range defaults {
|
|
if _, ok := data[k]; ok {
|
|
// Key exist
|
|
} else {
|
|
data[k] = v
|
|
}
|
|
}
|
|
err = WriteUserData(userID, data)
|
|
}
|
|
return
|
|
}
|
|
|
|
// ChangeCredentials : change username and/or password of a user
|
|
func ChangeCredentials(userID, username, password string) (err error) {
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = createError(032)
|
|
|
|
if userData, ok := data["users"].(map[string]any)[userID]; ok {
|
|
var salt = userData.(map[string]any)["_salt"].(string)
|
|
|
|
if len(username) > 0 {
|
|
userData.(map[string]any)["_username"] = SHA256(username, salt)
|
|
}
|
|
|
|
if len(password) > 0 {
|
|
userData.(map[string]any)["_password"] = hashPassword(password)
|
|
}
|
|
|
|
err = saveDatabase(data)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GetAllUserData : get all user data
|
|
func GetAllUserData() (allUserData map[string]any, err error) {
|
|
|
|
err = checkInit()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
var defaults = make(map[string]any)
|
|
defaults["dbVersion"] = "1.0"
|
|
defaults["hash"] = "sha256"
|
|
defaults["users"] = make(map[string]any)
|
|
saveDatabase(defaults)
|
|
data = defaults
|
|
}
|
|
|
|
allUserData = data["users"].(map[string]any)
|
|
return
|
|
}
|
|
|
|
// CheckTheValidityOfTheTokenFromHTTPHeader : get token from HTTP header
|
|
func CheckTheValidityOfTheTokenFromHTTPHeader(w http.ResponseWriter, r *http.Request) (writer http.ResponseWriter, newToken string, err error) {
|
|
err = createError(011)
|
|
for _, cookie := range r.Cookies() {
|
|
if cookie.Name == "Token" {
|
|
var token string
|
|
token, err = CheckTheValidityOfTheToken(cookie.Value)
|
|
//fmt.Println("T", token, err)
|
|
writer = SetCookieToken(w, token)
|
|
newToken = token
|
|
}
|
|
}
|
|
//fmt.Println(err)
|
|
return
|
|
}
|
|
|
|
// Framework tools
|
|
|
|
func checkInit() (err error) {
|
|
if !initAuthentication {
|
|
err = createError(000)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func saveDatabase(tmpMap any) (err error) {
|
|
|
|
jsonString, err := json.MarshalIndent(tmpMap, "", " ")
|
|
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = writeFileAtomic(database, []byte(jsonString), 0600)
|
|
|
|
return
|
|
}
|
|
|
|
// writeFileAtomic : Temp file + fsync + rename, so authentication.json is
|
|
// never observed half-written. (Private copy; this package cannot import src.)
|
|
func writeFileAtomic(name string, data []byte, perm os.FileMode) (err error) {
|
|
|
|
tmp, err := os.CreateTemp(filepath.Dir(name), ".tmp-*")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var tmpName = tmp.Name()
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
tmp.Close()
|
|
os.Remove(tmpName)
|
|
}
|
|
}()
|
|
|
|
if _, err = tmp.Write(data); err != nil {
|
|
return
|
|
}
|
|
|
|
if err = tmp.Sync(); err != nil {
|
|
return
|
|
}
|
|
|
|
if err = tmp.Close(); err != nil {
|
|
return
|
|
}
|
|
|
|
if err = os.Chmod(tmpName, perm); err != nil {
|
|
return
|
|
}
|
|
|
|
err = os.Rename(tmpName, name)
|
|
|
|
return
|
|
}
|
|
|
|
func loadDatabase() (err error) {
|
|
jsonString, err := os.ReadFile(database)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(jsonString), &data)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
h.Write([]byte("_remote_db"))
|
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
func randomString(n int) string {
|
|
const alphanum = "-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789aBcDeFgHiJkLmNoPqRsTuVwXyZ_"
|
|
|
|
var bytes = make([]byte, n)
|
|
rand.Read(bytes)
|
|
for i, b := range bytes {
|
|
bytes[i] = alphanum[b%byte(len(alphanum))]
|
|
}
|
|
return string(bytes)
|
|
}
|
|
|
|
func randomID(n int) string {
|
|
const alphanum = "ABCDEFGHJKLMNOPQRSTUVWXYZ0123456789"
|
|
|
|
var bytes = make([]byte, n)
|
|
rand.Read(bytes)
|
|
for i, b := range bytes {
|
|
bytes[i] = alphanum[b%byte(len(alphanum))]
|
|
}
|
|
return string(bytes)
|
|
}
|
|
|
|
func createError(errCode int) (err error) {
|
|
var errMsg string
|
|
switch errCode {
|
|
case 000:
|
|
errMsg = "Authentication has not yet been initialized"
|
|
case 001:
|
|
errMsg = "Default user already exists"
|
|
case 002:
|
|
errMsg = "No user id found for this token"
|
|
case 010:
|
|
errMsg = "User authentication failed"
|
|
case 011:
|
|
errMsg = "Session has expired"
|
|
case 020:
|
|
errMsg = "User already exists"
|
|
case 030:
|
|
errMsg = "User data could not be saved"
|
|
case 031:
|
|
errMsg = "User data could not be read"
|
|
case 032:
|
|
errMsg = "User ID was not found"
|
|
}
|
|
|
|
err = errors.New(errMsg)
|
|
return
|
|
}
|
|
|
|
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"] = hashPassword(password)
|
|
defaults["_salt"] = salt
|
|
defaults["_id"] = "id-" + randomID(idLength)
|
|
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()
|
|
|
|
if oldToken != "-" {
|
|
delete(tokens, oldToken)
|
|
}
|
|
|
|
evictExpiredTokensLocked()
|
|
|
|
for {
|
|
newToken = randomString(tokenLength)
|
|
if _, ok := tokens[newToken]; !ok {
|
|
break
|
|
}
|
|
}
|
|
|
|
var tmp = make(map[string]any)
|
|
tmp["id"] = id
|
|
tmp["expires"] = time.Now().Local().Add(time.Minute * time.Duration(tokenValidity))
|
|
|
|
tokens[newToken] = tmp
|
|
|
|
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
|
|
// side per token and refreshed on every authenticated request. A token of
|
|
// "-" clears the cookie (logout).
|
|
func SetCookieToken(w http.ResponseWriter, token string) http.ResponseWriter {
|
|
cookie := http.Cookie{
|
|
Name: "Token",
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
}
|
|
if token == "-" {
|
|
cookie.MaxAge = -1
|
|
}
|
|
http.SetCookie(w, &cookie)
|
|
return w
|
|
}
|