Skip to content

Commit

Permalink
Add refresh tokens for persistent login, logout button
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hrfee committed Aug 19, 2020
1 parent 29a79a1 commit d144077
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 61 deletions.
5 changes: 5 additions & 0 deletions api.go
Expand Up @@ -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 {
Expand Down
127 changes: 95 additions & 32 deletions auth.go
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/base64"
"fmt"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
92 changes: 63 additions & 29 deletions data/static/admin.js
Expand Up @@ -531,20 +531,7 @@ document.getElementById('inviteForm').onsubmit = function() {
return false;
};

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...';
function tryLogin(username, password, modal, button) {
let req = new XMLHttpRequest();
req.responseType = 'json';
req.onreadystatechange = function() {
Expand All @@ -554,22 +541,27 @@ document.getElementById('loginForm').onsubmit = function() {
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';
if (modal) {
button.disabled = false;
button.textContent = errormsg;
if (!button.classList.contains('btn-danger')) {
button.classList.add('btn-danger');
button.classList.remove('btn-primary');
}
}, 4000)
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');
Expand All @@ -582,13 +574,33 @@ document.getElementById('loginForm').onsubmit = function() {
addOptions(59, minutes);
minutes.selected = "30";
checkDuration();
loginModal.hide();
if (modal) {
loginModal.hide();
}
document.getElementById('logoutButton').setAttribute('style', '');
}
}
};
req.open("GET", "/getToken", true);
req.setRequestHeader("Authorization", "Basic " + btoa(details['username'] + ":" + details['password']));
req.setRequestHeader("Authorization", "Basic " + btoa(username + ":" + password));
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;
};

Expand Down Expand Up @@ -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 = {};
Expand Down
3 changes: 3 additions & 0 deletions data/templates/admin.html
Expand Up @@ -258,6 +258,9 @@ <h1>
<button type="button" class="btn btn-primary" id="openSettings">
Settings <i class="fa fa-cog"></i>
</button>
<button type="button" class="btn btn-danger" id="logoutButton" style="display: none;">
Logout <i class="fa fa-sign-out"></i>
</button>
</div>
<div class="card mb-3 linkGroup">
<div class="card-header">Current Invites</div>
Expand Down
2 changes: 2 additions & 0 deletions main.go
Expand Up @@ -42,6 +42,7 @@ type appContext struct {
bsVersion int
jellyfinLogin bool
users []User
invalidIds []string
jf Jellyfin
authJf Jellyfin
datePattern string
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit d144077

Please sign in to comment.