diff --git a/api.go b/api.go index db0575c..53b3566 100644 --- a/api.go +++ b/api.go @@ -609,6 +609,11 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { } } +func (app *appContext) Logout(gc *gin.Context) { + app.invalidIds = append(app.invalidIds, gc.GetString("userId")) + gc.JSON(200, map[string]bool{"success": true}) +} + // func Restart() error { // defer func() { // if r := recover(); r != nil { diff --git a/auth.go b/auth.go index 14b4941..cb92ca9 100644 --- a/auth.go +++ b/auth.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "fmt" "os" + "strconv" "strings" "time" @@ -40,7 +41,14 @@ func (app *appContext) authenticate(gc *gin.Context) { claims, ok := token.Claims.(jwt.MapClaims) var userId string var jfId string - if ok && token.Valid { + expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64) + if err != nil { + app.debug.Printf("Auth denied: %s", err) + respond(401, "Unauthorized", gc) + return + } + expiry := time.Unix(expiryUnix, 0) + if ok && token.Valid && claims["type"].(string) == "bearer" && expiry.After(time.Now()) { userId = claims["id"].(string) jfId = claims["jfid"].(string) } else { @@ -76,71 +84,126 @@ func (app *appContext) GetToken(gc *gin.Context) { auth, _ := base64.StdEncoding.DecodeString(header[1]) creds := strings.SplitN(string(auth), ":", 2) match := false - var userId string + var userId, jfId string for _, user := range app.users { if user.Username == creds[0] && user.Password == creds[1] { match = true userId = user.UserID } } - jfId := "" if !match { if !app.jellyfinLogin { app.info.Println("Auth failed: Invalid username and/or password") respond(401, "Unauthorized", gc) return } - var status int - var err error - var user map[string]interface{} - user, status, err = app.authJf.authenticate(creds[0], creds[1]) - if status != 200 || err != nil { - if status == 401 || status == 400 { - app.info.Println("Auth failed: Invalid username and/or password") - respond(401, "Invalid username/password", gc) + if creds[1] == "" { + token, err := jwt.Parse(creds[0], func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + app.debug.Printf("Invalid JWT signing method %s", token.Header["alg"]) + return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"]) + } + return []byte(os.Getenv("JFA_SECRET")), nil + }) + if err != nil { + app.debug.Printf("Auth denied: %s", err) + respond(401, "Unauthorized", gc) return } - app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status) - respond(500, "Jellyfin error", gc) - return - } else { - jfId = user["Id"].(string) - if app.config.Section("ui").Key("admin_only").MustBool(true) { - if !user["Policy"].(map[string]interface{})["IsAdministrator"].(bool) { - app.debug.Printf("Auth failed: User \"%s\" isn't admin", creds[0]) + claims, ok := token.Claims.(jwt.MapClaims) + for _, id := range app.invalidIds { + if claims["id"].(string) == id { + app.debug.Printf("Auth denied: Refresh token in blocklist") respond(401, "Unauthorized", gc) + return } } - newuser := User{} - newuser.UserID = shortuuid.New() - userId = newuser.UserID - // uuid, nothing else identifiable! - app.debug.Printf("Token generated for user \"%s\"", creds[0]) - app.users = append(app.users, newuser) + expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64) + if err != nil { + app.debug.Printf("Auth denied: %s", err) + respond(401, "Unauthorized", gc) + return + } + expiry := time.Unix(expiryUnix, 0) + if ok && token.Valid && claims["type"].(string) == "refresh" && expiry.After(time.Now()) { + userId = claims["id"].(string) + jfId = claims["jfid"].(string) + } else { + app.debug.Printf("Invalid token (invalid or not refresh type)") + respond(401, "Unauthorized", gc) + return + } + } else { + var status int + var err error + var user map[string]interface{} + user, status, err = app.authJf.authenticate(creds[0], creds[1]) + if status != 200 || err != nil { + if status == 401 || status == 400 { + app.info.Println("Auth failed: Invalid username and/or password") + respond(401, "Invalid username/password", gc) + return + } + app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status) + respond(500, "Jellyfin error", gc) + return + } else { + jfId = user["Id"].(string) + if app.config.Section("ui").Key("admin_only").MustBool(true) { + if !user["Policy"].(map[string]interface{})["IsAdministrator"].(bool) { + app.debug.Printf("Auth failed: User \"%s\" isn't admin", creds[0]) + respond(401, "Unauthorized", gc) + } + } + newuser := User{} + newuser.UserID = shortuuid.New() + userId = newuser.UserID + // uuid, nothing else identifiable! + app.debug.Printf("Token generated for user \"%s\"", creds[0]) + app.users = append(app.users, newuser) + } } } - token, err := CreateToken(userId, jfId) + token, refresh, err := CreateToken(userId, jfId) if err != nil { respond(500, "Error generating token", gc) } - resp := map[string]string{"token": token} + resp := map[string]string{"token": token, "refresh": refresh} gc.JSON(200, resp) } -func CreateToken(userId string, jfId string) (string, error) { +func CreateToken(userId string, jfId string) (string, string, error) { + var token, refresh string + var err error claims := jwt.MapClaims{ "valid": true, "id": userId, - "exp": time.Now().Add(time.Minute * 20).Unix(), + "exp": strconv.FormatInt(time.Now().Add(time.Minute*20).Unix(), 10), "jfid": jfId, + "type": "bearer", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - token, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) + token, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) if err != nil { - return "", err + return "", "", err } - return token, nil + + claims = jwt.MapClaims{ + "valid": true, + "id": userId, + "exp": strconv.FormatInt(time.Now().Add(time.Hour*24).Unix(), 10), + "jfid": jfId, + "type": "refresh", + } + + tk = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + refresh, err = tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) + if err != nil { + return "", "", err + } + + return token, refresh, nil } func respond(code int, message string, gc *gin.Context) { diff --git a/data/static/admin.js b/data/static/admin.js index bbcb13e..5355c25 100644 --- a/data/static/admin.js +++ b/data/static/admin.js @@ -531,6 +531,61 @@ document.getElementById('inviteForm').onsubmit = function() { return false; }; +function tryLogin(username, password, modal, button) { + let req = new XMLHttpRequest(); + req.responseType = 'json'; + req.onreadystatechange = function() { + if (this.readyState == 4) { + if (this.status != 200) { + let errormsg = req.response["error"]; + if (errormsg == "") { + errormsg = "Unknown error" + } + if (modal) { + button.disabled = false; + button.textContent = errormsg; + if (!button.classList.contains('btn-danger')) { + button.classList.add('btn-danger'); + button.classList.remove('btn-primary'); + } + setTimeout(function () { + if (button.classList.contains('btn-danger')) { + button.classList.add('btn-primary'); + button.classList.remove('btn-danger'); + button.textContent = 'Login'; + } + }, 4000) + } else { + loginModal.show(); + } + } else { + const data = this.response; + window.token = data['token']; + document.cookie = "refresh=" + data['refresh']; + generateInvites(); + const interval = setInterval(function() { generateInvites(); }, 60 * 1000); + let day = document.getElementById('days'); + addOptions(30, day); + day.selected = "0"; + let hour = document.getElementById('hours'); + addOptions(24, hour); + hour.selected = "0"; + let minutes = document.getElementById('minutes'); + addOptions(59, minutes); + minutes.selected = "30"; + checkDuration(); + if (modal) { + loginModal.hide(); + } + document.getElementById('logoutButton').setAttribute('style', ''); + } + } + }; + req.open("GET", "/getToken", true); + req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password)); + req.send(); +} + document.getElementById('loginForm').onsubmit = function() { window.token = ""; let details = serializeForm('loginForm'); @@ -545,50 +600,7 @@ document.getElementById('loginForm').onsubmit = function() { button.innerHTML = '' + 'Loading...'; - let req = new XMLHttpRequest(); - req.responseType = 'json'; - req.onreadystatechange = function() { - if (this.readyState == 4) { - if (this.status != 200) { - let errormsg = req.response["error"]; - if (errormsg == "") { - errormsg = "Unknown error" - } - button.disabled = false; - button.textContent = errormsg; - if (!button.classList.contains('btn-danger')) { - button.classList.add('btn-danger'); - button.classList.remove('btn-primary'); - } - setTimeout(function () { - if (button.classList.contains('btn-danger')) { - button.classList.add('btn-primary'); - button.classList.remove('btn-danger'); - button.textContent = 'Login'; - } - }, 4000) - } else { - const data = this.response; - window.token = data['token']; - generateInvites(); - const interval = setInterval(function() { generateInvites(); }, 60 * 1000); - let day = document.getElementById('days'); - addOptions(30, day); - day.selected = "0"; - let hour = document.getElementById('hours'); - addOptions(24, hour); - hour.selected = "0"; - let minutes = document.getElementById('minutes'); - addOptions(59, minutes); - minutes.selected = "30"; - checkDuration(); - loginModal.hide(); - } - } - }; - req.open("GET", "/getToken", true); - req.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password'])); - req.send(); + tryLogin(details['username'], details['password'], true, button) return false; }; @@ -794,7 +806,29 @@ document.getElementById('openUsers').onclick = function () { }; generateInvites(empty = true); -loginModal.show(); + +let refreshToken = getCookie("refresh") +if (refreshToken != "") { + tryLogin(refreshToken, "", false) +} else { + loginModal.show(); +} + +document.getElementById('logoutButton').onclick = function () { + let req = new XMLHttpRequest(); + req.open("POST", "/logout", true); + req.responseType = 'json'; + req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":")); + req.onreadystatechange = function() { + if (this.readyState == 4 && this.status == 200) { + window.token = ''; + document.cookie = 'refresh=;'; + location.reload(); + return false; + } + }; + req.send(); +} var config = {}; var modifiedConfig = {}; diff --git a/data/templates/admin.html b/data/templates/admin.html index 7ffbaee..5b32df4 100644 --- a/data/templates/admin.html +++ b/data/templates/admin.html @@ -258,6 +258,9 @@ +
Current Invites
diff --git a/main.go b/main.go index 5784e32..f877219 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ type appContext struct { bsVersion int jellyfinLogin bool users []User + invalidIds []string jf Jellyfin authJf Jellyfin datePattern string @@ -328,6 +329,7 @@ func main() { router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false))) router.GET("/invite/:invCode", app.InviteProxy) api := router.Group("/", app.webAuth()) + api.POST("/logout", app.Logout) api.POST("/generateInvite", app.GenerateInvite) api.GET("/getInvites", app.GetInvites) api.POST("/setNotify", app.SetNotify)