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:
parent
29a79a1ce1
commit
d144077e62
5
api.go
5
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 {
|
// func Restart() error {
|
||||||
// defer func() {
|
// defer func() {
|
||||||
// if r := recover(); r != nil {
|
// if r := recover(); r != nil {
|
||||||
|
127
auth.go
127
auth.go
@ -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,71 +84,126 @@ 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
|
||||||
}
|
}
|
||||||
var status int
|
if creds[1] == "" {
|
||||||
var err error
|
token, err := jwt.Parse(creds[0], func(token *jwt.Token) (interface{}, error) {
|
||||||
var user map[string]interface{}
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
user, status, err = app.authJf.authenticate(creds[0], creds[1])
|
app.debug.Printf("Invalid JWT signing method %s", token.Header["alg"])
|
||||||
if status != 200 || err != nil {
|
return nil, fmt.Errorf("Unexpected signing method %v", token.Header["alg"])
|
||||||
if status == 401 || status == 400 {
|
}
|
||||||
app.info.Println("Auth failed: Invalid username and/or password")
|
return []byte(os.Getenv("JFA_SECRET")), nil
|
||||||
respond(401, "Invalid username/password", gc)
|
})
|
||||||
|
if err != nil {
|
||||||
|
app.debug.Printf("Auth denied: %s", err)
|
||||||
|
respond(401, "Unauthorized", gc)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.err.Printf("Auth failed: Couldn't authenticate with Jellyfin: Code %d", status)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
respond(500, "Jellyfin error", gc)
|
for _, id := range app.invalidIds {
|
||||||
return
|
if claims["id"].(string) == id {
|
||||||
} else {
|
app.debug.Printf("Auth denied: Refresh token in blocklist")
|
||||||
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)
|
respond(401, "Unauthorized", gc)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newuser := User{}
|
expiryUnix, err := strconv.ParseInt(claims["exp"].(string), 10, 64)
|
||||||
newuser.UserID = shortuuid.New()
|
if err != nil {
|
||||||
userId = newuser.UserID
|
app.debug.Printf("Auth denied: %s", err)
|
||||||
// uuid, nothing else identifiable!
|
respond(401, "Unauthorized", gc)
|
||||||
app.debug.Printf("Token generated for user \"%s\"", creds[0])
|
return
|
||||||
app.users = append(app.users, newuser)
|
}
|
||||||
|
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 {
|
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) {
|
||||||
|
@ -531,6 +531,61 @@ document.getElementById('inviteForm').onsubmit = function() {
|
|||||||
return false;
|
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() {
|
document.getElementById('loginForm').onsubmit = function() {
|
||||||
window.token = "";
|
window.token = "";
|
||||||
let details = serializeForm('loginForm');
|
let details = serializeForm('loginForm');
|
||||||
@ -545,50 +600,7 @@ document.getElementById('loginForm').onsubmit = function() {
|
|||||||
button.innerHTML =
|
button.innerHTML =
|
||||||
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" style="margin-right: 0.5rem;"></span>' +
|
||||||
'Loading...';
|
'Loading...';
|
||||||
let req = new XMLHttpRequest();
|
tryLogin(details['username'], details['password'], true, button)
|
||||||
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();
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -794,7 +806,29 @@ document.getElementById('openUsers').onclick = function () {
|
|||||||
};
|
};
|
||||||
|
|
||||||
generateInvites(empty = true);
|
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 config = {};
|
||||||
var modifiedConfig = {};
|
var modifiedConfig = {};
|
||||||
|
@ -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>
|
||||||
|
2
main.go
2
main.go
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user