1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-23 01:20:11 +00:00

Add refresh tokens for persistent login, logout button

the main JWT is stored temporarily, whereas the refresh token is stored
as a cookie and can only be used to obtain a new main token. Logout
button adds token to blocklist internally and deletes JWT and refresh
token from browser storage.
This commit is contained in:
Harvey Tindall 2020-08-19 22:30:54 +01:00
parent 29a79a1ce1
commit d144077e62
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
5 changed files with 184 additions and 77 deletions

5
api.go
View File

@ -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 { // func Restart() error {
// defer func() { // defer func() {
// if r := recover(); r != nil { // if r := recover(); r != nil {

83
auth.go
View File

@ -4,6 +4,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@ -40,7 +41,14 @@ func (app *appContext) authenticate(gc *gin.Context) {
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
var userId string var userId string
var jfId 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) userId = claims["id"].(string)
jfId = claims["jfid"].(string) jfId = claims["jfid"].(string)
} else { } else {
@ -76,20 +84,56 @@ func (app *appContext) GetToken(gc *gin.Context) {
auth, _ := base64.StdEncoding.DecodeString(header[1]) auth, _ := base64.StdEncoding.DecodeString(header[1])
creds := strings.SplitN(string(auth), ":", 2) creds := strings.SplitN(string(auth), ":", 2)
match := false match := false
var userId string var userId, jfId string
for _, user := range app.users { for _, user := range app.users {
if user.Username == creds[0] && user.Password == creds[1] { if user.Username == creds[0] && user.Password == creds[1] {
match = true match = true
userId = user.UserID userId = user.UserID
} }
} }
jfId := ""
if !match { if !match {
if !app.jellyfinLogin { if !app.jellyfinLogin {
app.info.Println("Auth failed: Invalid username and/or password") app.info.Println("Auth failed: Invalid username and/or password")
respond(401, "Unauthorized", gc) respond(401, "Unauthorized", gc)
return return
} }
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
}
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
}
}
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 status int
var err error var err error
var user map[string]interface{} var user map[string]interface{}
@ -119,28 +163,47 @@ func (app *appContext) GetToken(gc *gin.Context) {
app.users = append(app.users, newuser) app.users = append(app.users, newuser)
} }
} }
token, err := CreateToken(userId, jfId) }
token, refresh, err := CreateToken(userId, jfId)
if err != nil { if err != nil {
respond(500, "Error generating token", gc) respond(500, "Error generating token", gc)
} }
resp := map[string]string{"token": token} resp := map[string]string{"token": token, "refresh": refresh}
gc.JSON(200, resp) 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{ claims := jwt.MapClaims{
"valid": true, "valid": true,
"id": userId, "id": userId,
"exp": time.Now().Add(time.Minute * 20).Unix(), "exp": strconv.FormatInt(time.Now().Add(time.Minute*20).Unix(), 10),
"jfid": jfId, "jfid": jfId,
"type": "bearer",
} }
tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 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 { 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) { func respond(code int, message string, gc *gin.Context) {

View File

@ -531,20 +531,7 @@ document.getElementById('inviteForm').onsubmit = function() {
return false; return false;
}; };
document.getElementById('loginForm').onsubmit = function() { function tryLogin(username, password, modal, button) {
window.token = "";
let details = serializeForm('loginForm');
// let errorArea = document.getElementById('loginErrorArea');
// errorArea.textContent = '';
let button = document.getElementById('loginSubmit');
if (button.classList.contains('btn-danger')) {
button.classList.add('btn-primary');
button.classList.remove('btn-danger');
}
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
let req = new XMLHttpRequest(); let req = new XMLHttpRequest();
req.responseType = 'json'; req.responseType = 'json';
req.onreadystatechange = function() { req.onreadystatechange = function() {
@ -554,6 +541,7 @@ document.getElementById('loginForm').onsubmit = function() {
if (errormsg == "") { if (errormsg == "") {
errormsg = "Unknown error" errormsg = "Unknown error"
} }
if (modal) {
button.disabled = false; button.disabled = false;
button.textContent = errormsg; button.textContent = errormsg;
if (!button.classList.contains('btn-danger')) { if (!button.classList.contains('btn-danger')) {
@ -567,9 +555,13 @@ document.getElementById('loginForm').onsubmit = function() {
button.textContent = 'Login'; button.textContent = 'Login';
} }
}, 4000) }, 4000)
} else {
loginModal.show();
}
} else { } else {
const data = this.response; const data = this.response;
window.token = data['token']; window.token = data['token'];
document.cookie = "refresh=" + data['refresh'];
generateInvites(); generateInvites();
const interval = setInterval(function() { generateInvites(); }, 60 * 1000); const interval = setInterval(function() { generateInvites(); }, 60 * 1000);
let day = document.getElementById('days'); let day = document.getElementById('days');
@ -582,13 +574,33 @@ document.getElementById('loginForm').onsubmit = function() {
addOptions(59, minutes); addOptions(59, minutes);
minutes.selected = "30"; minutes.selected = "30";
checkDuration(); checkDuration();
if (modal) {
loginModal.hide(); loginModal.hide();
} }
document.getElementById('logoutButton').setAttribute('style', '');
}
} }
}; };
req.open("GET", "/getToken", true); req.open("GET", "/getToken", true);
req.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password'])); req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
req.send(); req.send();
}
document.getElementById('loginForm').onsubmit = function() {
window.token = "";
let details = serializeForm('loginForm');
// let errorArea = document.getElementById('loginErrorArea');
// errorArea.textContent = '';
let button = document.getElementById('loginSubmit');
if (button.classList.contains('btn-danger')) {
button.classList.add('btn-primary');
button.classList.remove('btn-danger');
}
button.disabled = true;
button.innerHTML =
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
'Loading...';
tryLogin(details['username'], details['password'], true, button)
return false; return false;
}; };
@ -794,7 +806,29 @@ document.getElementById('openUsers').onclick = function () {
}; };
generateInvites(empty = true); generateInvites(empty = true);
let refreshToken = getCookie("refresh")
if (refreshToken != "") {
tryLogin(refreshToken, "", false)
} else {
loginModal.show(); 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 config = {};
var modifiedConfig = {}; var modifiedConfig = {};

View File

@ -258,6 +258,9 @@
<button type="button" class="btn btn-primary" id="openSettings"> <button type="button" class="btn btn-primary" id="openSettings">
Settings <i class="fa fa-cog"></i> Settings <i class="fa fa-cog"></i>
</button> </button>
<button type="button" class="btn btn-danger" id="logoutButton" style="display: none;">
Logout <i class="fa fa-sign-out"></i>
</button>
</div> </div>
<div class="card mb-3 linkGroup"> <div class="card mb-3 linkGroup">
<div class="card-header">Current Invites</div> <div class="card-header">Current Invites</div>

View File

@ -42,6 +42,7 @@ type appContext struct {
bsVersion int bsVersion int
jellyfinLogin bool jellyfinLogin bool
users []User users []User
invalidIds []string
jf Jellyfin jf Jellyfin
authJf Jellyfin authJf Jellyfin
datePattern string datePattern string
@ -328,6 +329,7 @@ func main() {
router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false))) router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
router.GET("/invite/:invCode", app.InviteProxy) router.GET("/invite/:invCode", app.InviteProxy)
api := router.Group("/", app.webAuth()) api := router.Group("/", app.webAuth())
api.POST("/logout", app.Logout)
api.POST("/generateInvite", app.GenerateInvite) api.POST("/generateInvite", app.GenerateInvite)
api.GET("/getInvites", app.GetInvites) api.GET("/getInvites", app.GetInvites)
api.POST("/setNotify", app.SetNotify) api.POST("/setNotify", app.SetNotify)