- Add ez deploy script
- Half implemented JWT refresh tokens, need to finish JS implementation
This commit is contained in:
Daniel Mason 2021-04-06 20:28:04 +12:00
parent 7c3b98a828
commit fb9ebef49c
Signed by: idanoo
GPG Key ID: 387387CDBC02F132
18 changed files with 228 additions and 29 deletions

View File

@ -10,7 +10,8 @@ REDIS_PREFIX="gs:"
REDIS_AUTH="" REDIS_AUTH=""
JWT_SECRET= JWT_SECRET=
JWT_EXPIRY=86400 JWT_EXPIRY=1800
REFRESH_EXPIRY=604800
REVERSE_PROXIES=127.0.0.1 REVERSE_PROXIES=127.0.0.1
PORT=42069 PORT=42069

View File

@ -3,7 +3,7 @@ stages:
- bundle - bundle
variables: variables:
VERSION: 0.0.20 VERSION: 0.0.21
build-go: build-go:
image: golang:1.16.2 image: golang:1.16.2

View File

@ -39,6 +39,18 @@ func main() {
goscrobble.JwtExpiry = time.Duration(i) * time.Second 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 // Ignore reverse proxies
goscrobble.ReverseProxies = strings.Split(os.Getenv("REVERSE_PROXIES"), ",") goscrobble.ReverseProxies = strings.Split(os.Getenv("REVERSE_PROXIES"), ",")

View File

@ -1,3 +1,7 @@
# 0.0.21
- Add ez deploy script
- Half implemented JWT refresh tokens, need to finish JS implementation
# 0.0.20 # 0.0.20
- Return related data on artist/album/track endpoints - Return related data on artist/album/track endpoints
- Scrobble table now links to tracks - Scrobble table now links to tracks

View File

@ -20,7 +20,8 @@ These are stored in `web/.env.production` and `web/.env.development`
REDIS_AUTH="" // Redis password REDIS_AUTH="" // Redis password
JWT_SECRET= // 32+ Char JWT secret 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 REVERSE_PROXIES=127.0.0.1 // Comma separated list of servers to ignore for IP logs
PORT=42069 // Server port PORT=42069 // Server port

19
init/deploy.sh Executable file
View File

@ -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

View File

@ -21,6 +21,7 @@ type MultiScrobblerRequest struct {
func ParseMultiScrobblerInput(userUUID string, data MultiScrobblerRequest, ip net.IP, tx *sql.Tx) error { func ParseMultiScrobblerInput(userUUID string, data MultiScrobblerRequest, ip net.IP, tx *sql.Tx) error {
// Cache key // Cache key
json := fmt.Sprintf("%s:%s:%s:%s", data.PlayedAt, data.Track, data.Album, userUUID) json := fmt.Sprintf("%s:%s:%s:%s", data.PlayedAt, data.Track, data.Album, userUUID)
fmt.Printf(json)
redisKey := getMd5(json) redisKey := getMd5(json)
if getRedisKeyExists(redisKey) { if getRedisKeyExists(redisKey) {
return nil return nil

View File

@ -1,6 +1,8 @@
package goscrobble package goscrobble
import ( import (
"errors"
"fmt"
"time" "time"
"github.com/dgrijalva/jwt-go" "github.com/dgrijalva/jwt-go"
@ -12,14 +14,21 @@ var JwtToken []byte
// JwtExpiry - Expiry in seconds // JwtExpiry - Expiry in seconds
var JwtExpiry time.Duration var JwtExpiry time.Duration
// RefereshExpiry - Expiry for refresh token
var RefereshExpiry time.Duration
type CustomClaims struct { type CustomClaims struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Admin bool `json:"admin"` Admin bool `json:"admin"`
RefreshToken string `json:"refresh_token"`
RefreshExp int `json:"refresh_exp"`
jwt.StandardClaims jwt.StandardClaims
} }
func generateJWTToken(user User) (string, error) { func generateJWTToken(user User, existingRefresh string) (string, error) {
refreshToken := generateToken(64)
atClaims := jwt.MapClaims{} atClaims := jwt.MapClaims{}
atClaims["sub"] = user.UUID atClaims["sub"] = user.UUID
atClaims["username"] = user.Username atClaims["username"] = user.Username
@ -27,12 +36,25 @@ func generateJWTToken(user User) (string, error) {
atClaims["admin"] = user.Admin atClaims["admin"] = user.Admin
atClaims["iat"] = time.Now().Unix() atClaims["iat"] = time.Now().Unix()
atClaims["exp"] = time.Now().Add(JwtExpiry).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) at := jwt.NewWithClaims(jwt.SigningMethodHS512, atClaims)
token, err := at.SignedString(JwtToken) token, err := at.SignedString(JwtToken)
if err != nil { if err != nil {
return "", err 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 return token, nil
} }

View File

@ -59,6 +59,7 @@ func HandleRequests(port string) {
v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST") v1.HandleFunc("/sendreset", limitMiddleware(handleSendReset, heavyLimiter)).Methods("POST")
v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST") v1.HandleFunc("/resetpassword", limitMiddleware(handleResetPassword, heavyLimiter)).Methods("POST")
v1.HandleFunc("/serverinfo", getServerInfo).Methods("GET") v1.HandleFunc("/serverinfo", getServerInfo).Methods("GET")
v1.HandleFunc("/refresh", limitMiddleware(handleTokenRefresh, standardLimiter)).Methods("POST")
// Redirect from Spotify Oauth // Redirect from Spotify Oauth
v1.HandleFunc("/link/spotify", limitMiddleware(postSpotifyReponse, lightLimiter)) v1.HandleFunc("/link/spotify", limitMiddleware(postSpotifyReponse, lightLimiter))
@ -143,6 +144,33 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
w.Write(data) 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 // handleStats - Returns stats for homepage
func handleStats(w http.ResponseWriter, r *http.Request) { func handleStats(w http.ResponseWriter, r *http.Request) {
stats, err := getStats() stats, err := getStats()
@ -293,8 +321,8 @@ func handleIngress(w http.ResponseWriter, r *http.Request, userUuid string) {
} }
// getUser - Return personal userprofile // getUser - Return personal userprofile
func getUser(w http.ResponseWriter, r *http.Request, jwtUser string, reqUser string) { func getUser(w http.ResponseWriter, r *http.Request, claims CustomClaims, reqUser string) {
// We don't this var most of the time jwtUser := claims.Subject
userFull, err := getUserByUUID(jwtUser) userFull, err := getUserByUUID(jwtUser)
if err != nil { if err != nil {
throwOkError(w, "Failed to fetch user information") 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 // 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) userFull, err := getUserByUUID(jwtUser)
if err != nil { if err != nil {
throwOkError(w, "Failed to fetch user information") 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 // 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) resp, err := getScrobblesForUser(reqUser, 100, 1)
if err != nil { if err != nil {
throwOkError(w, "Failed to fetch scrobbles") throwOkError(w, "Failed to fetch scrobbles")
@ -516,7 +546,7 @@ func postSpotifyReponse(w http.ResponseWriter, r *http.Request) {
} }
// getSpotifyClientID - Returns public spotify APP ID // 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") key, err := getConfigValue("SPOTIFY_APP_ID")
if err != nil { if err != nil {
throwOkError(w, "Failed to get Spotify ID") 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 // deleteSpotifyLink - Unlinks spotify account
func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, u string, v string) { func deleteSpotifyLink(w http.ResponseWriter, r *http.Request, claims CustomClaims, v string) {
err := removeOauthToken(u, "spotify") jwtUser := claims.Subject
err := removeOauthToken(jwtUser, "spotify")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
throwOkError(w, "Failed to unlink spotify account") throwOkError(w, "Failed to unlink spotify account")
@ -556,7 +587,7 @@ func getServerInfo(w http.ResponseWriter, r *http.Request) {
} }
info := ServerInfo{ info := ServerInfo{
Version: "0.0.20", Version: "0.0.21",
RegistrationEnabled: cachedRegistrationEnabled, RegistrationEnabled: cachedRegistrationEnabled,
} }

View File

@ -45,7 +45,7 @@ func tokenMiddleware(next func(http.ResponseWriter, *http.Request, string)) http
} }
// jwtMiddleware - Validates middleware to a user // 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) { return func(w http.ResponseWriter, r *http.Request) {
fullToken := r.Header.Get("Authorization") fullToken := r.Header.Get("Authorization")
authToken := strings.Replace(fullToken, "Bearer ", "", 1) 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)
} }
} }

View File

@ -8,6 +8,13 @@ import (
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
type RefreshToken struct {
UUID string
User string
Token string
Expiry time.Time
}
// generateToken - Generates a unique token for user input // generateToken - Generates a unique token for user input
func generateToken(n int) string { func generateToken(n int) string {
rand.Seed(time.Now().UnixNano()) rand.Seed(time.Now().UnixNano())
@ -33,3 +40,38 @@ func getUserUuidForToken(token string) (string, error) {
return uuid, nil 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
}

View File

@ -135,7 +135,7 @@ func loginUser(logReq *LoginRequest, ip net.IP) ([]byte, error) {
} }
// Issue JWT + Response // Issue JWT + Response
token, err := generateJWTToken(user) token, err := generateJWTToken(user, "")
if err != nil { if err != nil {
log.Printf("Error generating JWT: %v", err) log.Printf("Error generating JWT: %v", err)
return resp, errors.New("Error logging in") return resp, errors.New("Error logging in")

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS `refresh_tokens`;

View File

@ -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;

View File

@ -1,17 +1,19 @@
import axios from 'axios'; import axios from 'axios';
import jwt from 'jwt-decode' import jwt from 'jwt-decode'
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AuthContext from '../Contexts/AuthContext';
import { useContext } from 'react';
function getHeaders() { function getHeaders() {
// TODO: move this to use Context values instead.
const user = JSON.parse(localStorage.getItem('user')); const user = JSON.parse(localStorage.getItem('user'));
if (user && user.jwt) { if (user && user.jwt) {
var unixtime = Math.round((new Date()).getTime() / 1000); var unixtime = Math.round((new Date()).getTime() / 1000);
if (user.exp < unixtime) { if (user.exp < unixtime) {
// TODO: Handle expiry nicer // Trigger refresh
toast.warning("Session expired. Please log in again") localStorage.removeItem('user');
// localStorage.removeItem('user'); window.location.reload();
// toast.warning("Session expired. Please log in again")
// window.location.reload(); // window.location.reload();
return {}; return {};
} }
@ -58,7 +60,9 @@ export const PostLogin = (formValues) => {
uuid: expandedUser.sub, uuid: expandedUser.sub,
exp: expandedUser.exp, exp: expandedUser.exp,
username: expandedUser.username, username: expandedUser.username,
admin: expandedUser.admin admin: expandedUser.admin,
refresh_token: expandedUser.refresh_token,
refresh_exp: expandedUser.refresh_exp,
} }
toast.success('Successfully logged in.'); 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) => { export const PostRegister = (formValues) => {
return axios.post(process.env.REACT_APP_API_URL + "register", formValues) return axios.post(process.env.REACT_APP_API_URL + "register", formValues)
.then((response) => { .then((response) => {

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AuthContext from './AuthContext'; import AuthContext from './AuthContext';
import { PostLogin, PostRegister, PostResetPassword } from '../Api/index'; import { PostLogin, PostRegister, PostResetPassword, PostRefreshToken } from '../Api/index';
const AuthContextProvider = ({ children }) => { const AuthContextProvider = ({ children }) => {
const [user, setUser] = useState(); const [user, setUser] = useState();
@ -9,10 +9,23 @@ const AuthContextProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
setLoading(true) 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) { if (user && user.jwt) {
// 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) setUser(user)
} }
}
setLoading(false) setLoading(false)
}, []); }, []);
@ -35,7 +48,11 @@ const AuthContextProvider = ({ children }) => {
}; };
const ResetPassword = (formValues) => { const ResetPassword = (formValues) => {
return PostResetPassword(formValues) return PostResetPassword(formValues);
}
const RefreshToken = (refreshToken) => {
return PostRefreshToken(refreshToken);
} }
const Logout = () => { const Logout = () => {
@ -51,6 +68,7 @@ const AuthContextProvider = ({ children }) => {
Login, Login,
Register, Register,
ResetPassword, ResetPassword,
RefreshToken,
loading, loading,
user, user,
}} }}

View File

@ -8,7 +8,7 @@ const About = () => {
About GoScrobble.com About GoScrobble.com
</h1> </h1>
<p className="aboutBody"> <p className="aboutBody">
Go-Scrobble is an open source music scorbbling service written in Go and React.<br/> Go-Scrobble is an open source music scrobbling service written in Go and React.<br/>
Used to track your listening history and build a profile to discover new music. Used to track your listening history and build a profile to discover new music.
</p> </p>
<a <a

View File

@ -8,7 +8,7 @@ const Home = () => {
return ( return (
<div className="pageWrapper"> <div className="pageWrapper">
<img src={logo} className="App-logo" alt="logo" /> <img src={logo} className="App-logo" alt="logo" />
<p className="homeText">Go-Scrobble is an open source music scrobbling service written in Go and React.</p> <p className="homeText">GoScrobble is an open source music scrobbling service.</p>
<HomeBanner /> <HomeBanner />
</div> </div>
); );