diff --git a/.env.example b/.env.example index 88f43140..653ef4d4 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,8 @@ REDIS_PREFIX="gs:" REDIS_AUTH="" JWT_SECRET= -JWT_EXPIRY=86400 +JWT_EXPIRY=1800 +REFRESH_EXPIRY=604800 REVERSE_PROXIES=127.0.0.1 PORT=42069 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 98986002..d852bfa2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,7 +3,7 @@ stages: - bundle variables: - VERSION: 0.0.20 + VERSION: 0.0.21 build-go: image: golang:1.16.2 diff --git a/cmd/go-scrobble/main.go b/cmd/go-scrobble/main.go index 18c56e8c..801c98fd 100644 --- a/cmd/go-scrobble/main.go +++ b/cmd/go-scrobble/main.go @@ -39,6 +39,18 @@ func main() { goscrobble.JwtExpiry = time.Duration(i) * time.Second } + // Store Refresh expiry + goscrobble.RefereshExpiry = (86400 * 7) + refreshExpiryStr := os.Getenv("REFRESH_EXPIRY") + if refreshExpiryStr != "" { + i, err := strconv.ParseFloat(refreshExpiryStr, 64) + if err != nil { + panic("Invalid REFRESH_EXPIRY value") + } + + goscrobble.RefereshExpiry = time.Duration(i) * time.Second + } + // Ignore reverse proxies goscrobble.ReverseProxies = strings.Split(os.Getenv("REVERSE_PROXIES"), ",") diff --git a/docs/changelog.md b/docs/changelog.md index 19f67cc1..1a6122d6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,7 @@ +# 0.0.21 +- Add ez deploy script +- Half implemented JWT refresh tokens, need to finish JS implementation + # 0.0.20 - Return related data on artist/album/track endpoints - Scrobble table now links to tracks diff --git a/docs/config.md b/docs/config.md index 2d7a2bf8..d61d7670 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,7 +20,8 @@ These are stored in `web/.env.production` and `web/.env.development` REDIS_AUTH="" // Redis password JWT_SECRET= // 32+ Char JWT secret - JWT_EXPIRY=86400 // JWT expiry + JWT_EXPIRY=1800 // JWT expiry in seconds + REFRESH_EXPIRY=604800 // Refresh token expiry REVERSE_PROXIES=127.0.0.1 // Comma separated list of servers to ignore for IP logs PORT=42069 // Server port diff --git a/init/deploy.sh b/init/deploy.sh new file mode 100755 index 00000000..33a8cd12 --- /dev/null +++ b/init/deploy.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Easy deploy script.. + +echo 'Fetching latest git commit' +git pull + +echo 'Building backend' +go build -o goscrobble cmd/go-scrobble/*.go + +cd web +echo 'Installing frontend packages' +npm install --production + +echo 'Building frontend' +npm run build --env production + +cd .. +echo 'Restarting Go service' +systemctl restart goscrobble.service \ No newline at end of file diff --git a/internal/goscrobble/ingress_multiscrobbler.go b/internal/goscrobble/ingress_multiscrobbler.go index 37720f87..91fa45ef 100644 --- a/internal/goscrobble/ingress_multiscrobbler.go +++ b/internal/goscrobble/ingress_multiscrobbler.go @@ -21,6 +21,7 @@ type MultiScrobblerRequest struct { func ParseMultiScrobblerInput(userUUID string, data MultiScrobblerRequest, ip net.IP, tx *sql.Tx) error { // Cache key json := fmt.Sprintf("%s:%s:%s:%s", data.PlayedAt, data.Track, data.Album, userUUID) + fmt.Printf(json) redisKey := getMd5(json) if getRedisKeyExists(redisKey) { return nil diff --git a/internal/goscrobble/jwt.go b/internal/goscrobble/jwt.go index 57d85e79..4e9d2390 100644 --- a/internal/goscrobble/jwt.go +++ b/internal/goscrobble/jwt.go @@ -1,6 +1,8 @@ package goscrobble import ( + "errors" + "fmt" "time" "github.com/dgrijalva/jwt-go" @@ -12,14 +14,21 @@ var JwtToken []byte // JwtExpiry - Expiry in seconds var JwtExpiry time.Duration +// RefereshExpiry - Expiry for refresh token +var RefereshExpiry time.Duration + type CustomClaims struct { - Username string `json:"username"` - Email string `json:"email"` - Admin bool `json:"admin"` + Username string `json:"username"` + Email string `json:"email"` + Admin bool `json:"admin"` + RefreshToken string `json:"refresh_token"` + RefreshExp int `json:"refresh_exp"` jwt.StandardClaims } -func generateJWTToken(user User) (string, error) { +func generateJWTToken(user User, existingRefresh string) (string, error) { + refreshToken := generateToken(64) + atClaims := jwt.MapClaims{} atClaims["sub"] = user.UUID atClaims["username"] = user.Username @@ -27,12 +36,25 @@ func generateJWTToken(user User) (string, error) { atClaims["admin"] = user.Admin atClaims["iat"] = time.Now().Unix() atClaims["exp"] = time.Now().Add(JwtExpiry).Unix() + atClaims["refresh_token"] = refreshToken + atClaims["refresh_exp"] = time.Now().Add(RefereshExpiry).Unix() at := jwt.NewWithClaims(jwt.SigningMethodHS512, atClaims) token, err := at.SignedString(JwtToken) if err != nil { return "", err } + // Store refresh token + err = insertRefreshToken(user.UUID, refreshToken) + if err != nil { + fmt.Println(err) + return token, errors.New("Failed to generate token") + } + + if existingRefresh != "" { + deleteRefreshToken(existingRefresh) + } + return token, nil } diff --git a/internal/goscrobble/server.go b/internal/goscrobble/server.go index c06e9237..6ede0d85 100644 --- a/internal/goscrobble/server.go +++ b/internal/goscrobble/server.go @@ -59,6 +59,7 @@ func HandleRequests(port string) { v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST") v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST") v1.HandleFunc("/serverinfo", getServerInfo).Methods("GET") + v1.HandleFunc("/refresh", limitMiddleware(handleTokenRefresh, standardLimiter)).Methods("POST") // Redirect from Spotify Oauth v1.HandleFunc("/link/spotify", limitMiddleware(postSpotifyReponse, lightLimiter)) @@ -143,6 +144,33 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { w.Write(data) } +// handleTokenRefresh - Refresh access token based on refresh token +func handleTokenRefresh(w http.ResponseWriter, r *http.Request) { + logReq := LoginResponse{} + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&logReq) + user, err := isValidRefreshToken(logReq.Token) + if err != nil { + throwOkError(w, "Invalid refresh token") + return + } + + // Issue JWT + Response + token, err := generateJWTToken(user, logReq.Token) + if err != nil { + throwOkError(w, "Failed to refresh Token") + return + } + + loginResp := LoginResponse{ + Token: token, + } + + resp, _ := json.Marshal(&loginResp) + w.WriteHeader(http.StatusOK) + w.Write(resp) +} + // handleStats - Returns stats for homepage func handleStats(w http.ResponseWriter, r *http.Request) { stats, err := getStats() @@ -293,8 +321,8 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) { } // getUser - Return personal userprofile -func getUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) { - // We don't this var most of the time +func getUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { + jwtUser := claims.Subject userFull, err := getUserByUUID(jwtUser) if err != nil { throwOkError(w, "Failed to fetch user information") @@ -324,7 +352,9 @@ func getUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser str } // patchUser - Update specific values -func patchUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) { +func patchUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { + jwtUser := claims.Subject + userFull, err := getUserByUUID(jwtUser) if err != nil { throwOkError(w, "Failed to fetch user information") @@ -350,7 +380,7 @@ func patchUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser s } // getScrobbles - Return an array of scrobbles -func getScrobbles(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) { +func getScrobbles(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) { resp, err := getScrobblesForUser(reqUser, 100, 1) if err != nil { throwOkError(w, "Failed to fetch scrobbles") @@ -516,7 +546,7 @@ func postSpotifyReponse(w http.ResponseWriter, r *http.Request) { } // getSpotifyClientID - Returns public spotify APP ID -func getSpotifyClientID(w http.ResponseWriter, r *http.Request, u string, v string) { +func getSpotifyClientID(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { key, err := getConfigValue("SPOTIFY_APP_ID") if err != nil { throwOkError(w, "Failed to get Spotify ID") @@ -533,8 +563,9 @@ func getSpotifyClientID(w http.ResponseWriter, r *http.Request, u string, v stri } // deleteSpotifyLink - Unlinks spotify account -func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, u string, v string) { - err := removeOauthToken(u, "spotify") +func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) { + jwtUser := claims.Subject + err := removeOauthToken(jwtUser, "spotify") if err != nil { fmt.Println(err) throwOkError(w, "Failed to unlink spotify account") @@ -556,7 +587,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) { } info := ServerInfo{ - Version: "0.0.20", + Version: "0.0.21", RegistrationEnabled: cachedRegistrationEnabled, } diff --git a/internal/goscrobble/server_middleware.go b/internal/goscrobble/server_middleware.go index 9fea0031..c3dcc5f4 100644 --- a/internal/goscrobble/server_middleware.go +++ b/internal/goscrobble/server_middleware.go @@ -45,7 +45,7 @@ func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http } // jwtMiddleware - Validates middleware to a user -func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string)) http.HandlerFunc { +func jwtMiddleware(next func(http.ResponseWriter, *http.Request, CustomClaims, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { fullToken := r.Header.Get("Authorization") authToken := strings.Replace(fullToken, "Bearer ", "", 1) @@ -62,7 +62,7 @@ func jwtMiddleware(next func(http.ResponseWriter, *http.Request, string, string) } } - next(w, r, claims.Subject, reqUuid) + next(w, r, claims, reqUuid) } } diff --git a/internal/goscrobble/tokens.go b/internal/goscrobble/tokens.go index 35408fac..e12cc637 100644 --- a/internal/goscrobble/tokens.go +++ b/internal/goscrobble/tokens.go @@ -8,6 +8,13 @@ import ( const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" +type RefreshToken struct { + UUID string + User string + Token string + Expiry time.Time +} + // generateToken - Generates a unique token for user input func generateToken(n int) string { rand.Seed(time.Now().UnixNano()) @@ -33,3 +40,38 @@ func getUserUuidForToken(token string) (string, error) { return uuid, nil } + +func insertRefreshToken(userUuid string, token string) error { + uuid := newUUID() + _, err := db.Exec("INSERT INTO `refresh_tokens` (`uuid`, `user`, `token`) VALUES (UUID_TO_BIN(?,true),UUID_TO_BIN(?,true),?)", + uuid, userUuid, token) + + return err +} + +func deleteRefreshToken(token string) error { + _, err := db.Exec("DELETE FROM `refresh_tokens` WHERE `token` = ?", token) + + return err +} + +func isValidRefreshToken(refreshTokenStr string) (User, error) { + var refresh RefreshToken + err := db.QueryRow("SELECT BIN_TO_UUID(`uuid`, true), BIN_TO_UUID(`user`, true), `token`, `expiry` FROM `refresh_tokens` WHERE `token` = ?", + refreshTokenStr).Scan(&refresh.UUID, &refresh.User, &refresh.Token, &refresh.Expiry) + if err != nil { + return User{}, errors.New("Invalid Refresh Token") + } + + // Validate Expiry + if refresh.Expiry.Unix() < time.Now().Unix() { + return User{}, errors.New("Invalid Refresh Token") + } + + user, err := getUserByUUID(refresh.User) + if err != nil { + return User{}, errors.New("Invalid Refresh Token") + } + + return user, nil +} diff --git a/internal/goscrobble/user.go b/internal/goscrobble/user.go index e35605f1..b90d2595 100644 --- a/internal/goscrobble/user.go +++ b/internal/goscrobble/user.go @@ -135,7 +135,7 @@ func loginUser(logReq *LoginRequest, ip net.IP) ([]byte, error) { } // Issue JWT + Response - token, err := generateJWTToken(user) + token, err := generateJWTToken(user, "") if err != nil { log.Printf("Error generating JWT: %v", err) return resp, errors.New("Error logging in") diff --git a/migrations/12_refreshtokens.down.sql b/migrations/12_refreshtokens.down.sql new file mode 100644 index 00000000..5d3e7ef0 --- /dev/null +++ b/migrations/12_refreshtokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `refresh_tokens`; \ No newline at end of file diff --git a/migrations/12_refreshtokens.up.sql b/migrations/12_refreshtokens.up.sql new file mode 100644 index 00000000..57e790ed --- /dev/null +++ b/migrations/12_refreshtokens.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `refresh_tokens` ( + `uuid` BINARY(16) PRIMARY KEY, + `user` BINARY(16), + `token` VARCHAR(64) NOT NULL, + `expiry` DATETIME NOT NULL DEFAULT NOW(), + KEY `userLookup` (`user`), + KEY `tokenLookup` (`token`), + KEY `expiryLookup` (`expiry`), + FOREIGN KEY (user) REFERENCES users(uuid) +) DEFAULT CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci; \ No newline at end of file diff --git a/web/src/Api/index.js b/web/src/Api/index.js index 22753ad8..481535b3 100644 --- a/web/src/Api/index.js +++ b/web/src/Api/index.js @@ -1,17 +1,19 @@ import axios from 'axios'; import jwt from 'jwt-decode' import { toast } from 'react-toastify'; +import AuthContext from '../Contexts/AuthContext'; +import { useContext } from 'react'; function getHeaders() { - // TODO: move this to use Context values instead. const user = JSON.parse(localStorage.getItem('user')); if (user && user.jwt) { var unixtime = Math.round((new Date()).getTime() / 1000); if (user.exp < unixtime) { - // TODO: Handle expiry nicer - toast.warning("Session expired. Please log in again") - // localStorage.removeItem('user'); + // Trigger refresh + localStorage.removeItem('user'); + window.location.reload(); + // toast.warning("Session expired. Please log in again") // window.location.reload(); return {}; } @@ -58,7 +60,9 @@ export const PostLogin = (formValues) => { uuid: expandedUser.sub, exp: expandedUser.exp, username: expandedUser.username, - admin: expandedUser.admin + admin: expandedUser.admin, + refresh_token: expandedUser.refresh_token, + refresh_exp: expandedUser.refresh_exp, } toast.success('Successfully logged in.'); @@ -79,6 +83,39 @@ export const PostLogin = (formValues) => { }); }; +export const PostRefreshToken = (refreshToken) => { + return axios.post(process.env.REACT_APP_API_URL + "refresh", {token: refreshToken}) + .then((response) => { + if (response.data.token) { + let expandedUser = jwt(response.data.token) + let user = { + jwt: response.data.token, + uuid: expandedUser.sub, + exp: expandedUser.exp, + username: expandedUser.username, + admin: expandedUser.admin, + refresh_token: expandedUser.refresh_token, + refresh_exp: expandedUser.refresh_exp, + } + + return user; + } else { + toast.error(response.data.error ? response.data.error: 'An Unknown Error has occurred'); + return null + } + }).catch((error) => { + if (error.response === 401) { + toast.error('Unauthorized') + } else if (error.response === 429) { + toast.error('Rate limited. Please try again shortly') + } else { + toast.error('Failed to connect'); + } + return Promise.resolve(); + }); +}; + + export const PostRegister = (formValues) => { return axios.post(process.env.REACT_APP_API_URL + "register", formValues) .then((response) => { diff --git a/web/src/Contexts/AuthContextProvider.js b/web/src/Contexts/AuthContextProvider.js index 80445fe5..1ac053b6 100644 --- a/web/src/Contexts/AuthContextProvider.js +++ b/web/src/Contexts/AuthContextProvider.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { toast } from 'react-toastify'; import AuthContext from './AuthContext'; -import { PostLogin, PostRegister, PostResetPassword } from '../Api/index'; +import { PostLogin, PostRegister, PostResetPassword, PostRefreshToken } from '../Api/index'; const AuthContextProvider = ({ children }) => { const [user, setUser] = useState(); @@ -9,9 +9,22 @@ const AuthContextProvider = ({ children }) => { useEffect(() => { setLoading(true) - const user = JSON.parse(localStorage.getItem('user')); + let curTime = Math.round((new Date()).getTime() / 1000); + let user = JSON.parse(localStorage.getItem('user')); + + // Confirm JWT is set. if (user && user.jwt) { - setUser(user) + // Check refresh expiry is valid. + if (user.refresh_exp && (user.refresh_exp > curTime)) { + // Check if JWT is still valid + if (user.exp < curTime) { + // Refresh if not + user = RefreshToken(user.refresh_token) + localStorage.setItem('user', JSON.stringify(user)); + } + + setUser(user) + } } setLoading(false) }, []); @@ -35,7 +48,11 @@ const AuthContextProvider = ({ children }) => { }; const ResetPassword = (formValues) => { - return PostResetPassword(formValues) + return PostResetPassword(formValues); + } + + const RefreshToken = (refreshToken) => { + return PostRefreshToken(refreshToken); } const Logout = () => { @@ -51,6 +68,7 @@ const AuthContextProvider = ({ children }) => { Login, Register, ResetPassword, + RefreshToken, loading, user, }} diff --git a/web/src/Pages/About.js b/web/src/Pages/About.js index ef2bcc2a..82546b70 100644 --- a/web/src/Pages/About.js +++ b/web/src/Pages/About.js @@ -8,7 +8,7 @@ const About = () => { About GoScrobble.com

- Go-Scrobble is an open source music scorbbling service written in Go and React.
+ Go-Scrobble is an open source music scrobbling service written in Go and React.
Used to track your listening history and build a profile to discover new music.

{ return (
logo -

Go-Scrobble is an open source music scrobbling service written in Go and React.

+

GoScrobble is an open source music scrobbling service.

);