user authentication structure in place

This commit is contained in:
zomo
2026-01-05 23:10:00 -06:00
parent 9770ef9f21
commit 5c6e93c7e4
9 changed files with 261 additions and 112 deletions

View File

@@ -1,10 +1,12 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"net/url" "net/url"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"zomo.dev/largehadroncollider/ttv"
) )
func (server *ApiServer) loadEndpoints() { func (server *ApiServer) loadEndpoints() {
@@ -51,26 +53,42 @@ func (server *ApiServer) loadEndpoints() {
server.engine.GET("/auth", func(c *gin.Context) { server.engine.GET("/auth", func(c *gin.Context) {
q := c.Request.URL.Query() q := c.Request.URL.Query()
if resp := loadAuthQueryOk(q); resp != nil { if reqBody := loadAuthQueryOk(q); reqBody != nil {
// ok // ok
// TODO check state (need state system)
// TODO POST https://id.twitch.tv/oauth2/token - returns TwitchAuthTokenResp // TODO check state value (need state system)
// convert expiresIn to time.Time (minus like 15 minutes as a buffer period)
// UpdateUserAuth() authResp, err := server.twitch.Auth.DoAuth(server.conf, reqBody.Code)
// TODO return twitch ok (or err if can't POST) if err != nil {
} else if resp := loadAuthQueryErr(q); resp != nil { log.Println("Error Authenticating:")
log.Printf(" Parsed Request Body: %+v", reqBody)
log.Printf(" Authorization Error: %v", err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
err = server.twitch.Auth.UpdateUserDetails(server.db, authResp.AccessToken, authResp.RefreshToken, authResp.ExpiresIn)
if err != nil {
log.Println("Error Updating User Details:")
log.Printf(" Parsed Request Body: %+v", reqBody)
log.Printf(" Authorization Response: %+v", authResp)
log.Printf(" Error: %v", err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
// TODO return twitch ok
c.Status(http.StatusOK)
} else if reqBody := loadAuthQueryErr(q); reqBody != nil {
// err from twitch // err from twitch
// TODO check state (need state system) // TODO check state (need state system)
// TODO return twitch err // TODO return twitch err
c.AbortWithStatus(http.StatusBadRequest)
} else { } else {
// err in params // err in params
// TODO return param err // TODO return param err
c.AbortWithStatus(http.StatusBadRequest)
} }
// TODO auth response from twitch
// parse args as TwitchAuthRespOk or TwitchAuthRespErr
// verify state with db and client id with config
c.JSON(http.StatusOK, serverInfo)
}) })
} }
@@ -88,9 +106,9 @@ type TwitchAuthParams struct {
State string `json:"state"` State string `json:"state"`
} }
func loadAuthQueryOk(query url.Values) *TwitchAuthRespOk { func loadAuthQueryOk(query url.Values) *ttv.TwitchAuthRespOk {
if query.Has("code") && query.Has("scope") && query.Has("state") { if query.Has("code") && query.Has("scope") && query.Has("state") {
return &TwitchAuthRespOk{ return &ttv.TwitchAuthRespOk{
Code: query.Get("code"), Code: query.Get("code"),
Scope: query.Get("scope"), Scope: query.Get("scope"),
State: query.Get("state"), State: query.Get("state"),
@@ -99,9 +117,9 @@ func loadAuthQueryOk(query url.Values) *TwitchAuthRespOk {
return nil return nil
} }
func loadAuthQueryErr(query url.Values) *TwitchAuthRespErr { func loadAuthQueryErr(query url.Values) *ttv.TwitchAuthRespErr {
if query.Has("error") && query.Has("error_description") && query.Has("state") { if query.Has("error") && query.Has("error_description") && query.Has("state") {
return &TwitchAuthRespErr{ return &ttv.TwitchAuthRespErr{
Err: query.Get("error"), Err: query.Get("error"),
ErrDesc: query.Get("error_description"), ErrDesc: query.Get("error_description"),
State: query.Get("state"), State: query.Get("state"),
@@ -109,23 +127,3 @@ func loadAuthQueryErr(query url.Values) *TwitchAuthRespErr {
} }
return nil return nil
} }
type TwitchAuthRespOk struct {
Code string `json:"code"`
Scope string `json:"scope"`
State string `json:"state"`
}
type TwitchAuthRespErr struct {
Err string `json:"error"`
ErrDesc string `json:"error_description"`
State string `json:"state"`
}
type TwitchAuthTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
}

View File

@@ -10,7 +10,7 @@ import (
func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) { func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) {
engine := gin.Default() engine := gin.Default()
apiServer := &ApiServer{ engine, conf, dbConn, twitchConn } apiServer := &ApiServer{engine, conf, dbConn, twitchConn}
apiServer.loadEndpoints() apiServer.loadEndpoints()

View File

@@ -1,11 +1,15 @@
package db_cold package db_cold
import ( import (
"log"
"reflect"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
const EXPIRATION_BUFFER = 5 * time.Minute
type UserAuth struct { type UserAuth struct {
gorm.Model gorm.Model
UserID string `gorm:"primarykey"` UserID string `gorm:"primarykey"`
@@ -31,7 +35,7 @@ func (db *DBColdConn) GetAllUserAuth() ([]UserAuth, error) {
} }
// add or update user auth, based on ID // add or update user auth, based on ID
func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Time) error { func (db *DBColdConn) updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Time) error {
userAuth := UserAuth{ userAuth := UserAuth{
UserID: userID, UserID: userID,
UserName: userName, UserName: userName,
@@ -62,3 +66,36 @@ func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, r
return nil return nil
} }
func (db *DBColdConn) updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires time.Duration) error {
return db.updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken, time.Now().Add(tokenExpires-EXPIRATION_BUFFER))
}
func (db *DBColdConn) updateUserAuthInt(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires int) error {
return db.updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken, time.Duration(tokenExpires)*time.Second)
}
/** tokenExpires: time.Time | time.Duration | int */
func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, refreshToken string, tokenExpires any) error {
/*
expires can be:
- time.Time
- passed directly
- time.Duration
- converted to time.Time from now
- int
- converted to time.Duration in seconds then time.Time from now
*/
switch exp := tokenExpires.(type) {
case time.Time:
return db.updateUserAuthTime(userID, userName, userLogin, accessToken, refreshToken, exp)
case time.Duration:
return db.updateUserAuthDuration(userID, userName, userLogin, accessToken, refreshToken, exp)
case int:
return db.updateUserAuthInt(userID, userName, userLogin, accessToken, refreshToken, exp)
default:
log.Panicf("invalid type passed to any field in TwitchAuth.updateUserDetails(): %s, value: %v", reflect.TypeOf(tokenExpires), tokenExpires)
panic("we've already panicked")
}
}

View File

@@ -17,7 +17,7 @@ func InitDBColdConn(conf *util.Config) (*DBColdConn, error) {
} }
ctx := context.Background() ctx := context.Background()
cold := &DBColdConn{ db, ctx } cold := &DBColdConn{db, ctx}
cold.initUserAuth() cold.initUserAuth()
return cold, nil return cold, nil

View File

@@ -15,7 +15,7 @@ func InitDBConn(conf *util.Config) (*DBConn, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &DBConn{ hot, cold }, nil return &DBConn{hot, cold}, nil
} }
type DBConn struct { type DBConn struct {

12
test/main.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import (
"fmt"
"time"
)
func main() {
now := time.Now()
fmt.Println("Now:", now)
fmt.Println("Truncated:", now.Truncate(5*time.Minute).Add(5*time.Minute).Sub(now))
}

View File

@@ -2,83 +2,96 @@ package ttv
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"io"
"net/http"
"net/url"
"github.com/adeithe/go-twitch/api" "github.com/adeithe/go-twitch/api"
"zomo.dev/largehadroncollider/db" "zomo.dev/largehadroncollider/db"
"zomo.dev/largehadroncollider/db/db_cold"
"zomo.dev/largehadroncollider/util" "zomo.dev/largehadroncollider/util"
) )
// sign in to twitch with each saved tokens const TWITCH_AUTH_URL = "https://id.twitch.tv/oauth2/token"
func initAuth(conf *util.Config, dbConn *db.DBConn) (*TwitchAuth, error) { func initAuth(conf *util.Config, dbConn *db.DBConn) (*TwitchAuth, error) {
ctx := context.Background() ctx := context.Background()
tokens, err := dbConn.Cold.GetAllUserAuth()
if err != nil {
return nil, err
}
client := api.New(conf.ClientID) client := api.New(conf.ClientID)
twitchAuth := &TwitchAuth{ctx, client}
accounts, err := testTokens(ctx, client, tokens) // run once synchronously then start looping in a thread
if err != nil { twitchAuth.updateDetailsRefreshTokens(conf, dbConn)
return nil, err go twitchAuth.loopUpdateDetailsRefreshTokens(conf, dbConn)
}
for _, account := range accounts { return twitchAuth, nil
err := dbConn.Cold.UpdateUserAuth(account.UserID, account.UserName, account.UserLogin, account.AccessToken, account.RefreshToken, account.TokenExpires)
if err != nil {
return nil, err
}
}
return &TwitchAuth{ ctx, client, accounts }, nil
} }
type TwitchAuth struct { type TwitchAuth struct {
Ctx context.Context Ctx context.Context
Client *api.Client Client *api.Client
Accounts []db_cold.UserAuth
} }
func testTokens(ctx context.Context, client *api.Client, tokens []db_cold.UserAuth) ([]db_cold.UserAuth, error) { func (twitch *TwitchAuth) doAuth(formData url.Values) (TwitchAuthTokenResp, error) {
accounts := make([]db_cold.UserAuth, 0) resp, err := http.PostForm(TWITCH_AUTH_URL, formData)
for _, token := range tokens {
account, err := testToken(ctx, client, token)
if err != nil { if err != nil {
return nil, err return TwitchAuthTokenResp{}, err
} }
accounts = append(accounts, account) defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return TwitchAuthTokenResp{}, err
} }
return accounts, nil
var authResp TwitchAuthTokenResp
err = json.Unmarshal(bodyBytes, &authResp)
if err != nil {
return TwitchAuthTokenResp{}, err
}
return authResp, nil
} }
func testToken(ctx context.Context, client *api.Client, token db_cold.UserAuth) (db_cold.UserAuth, error) { func (twitch *TwitchAuth) DoAuth(conf *util.Config, code string) (TwitchAuthTokenResp, error) {
// TODO check refresh time, refresh token if needed formData := url.Values{
"client_id": {conf.ClientID},
"client_secret": {conf.ClientSecret},
"redirect_uri": {conf.RedirectURI},
"code": {code},
"grant_type": {"authorization_code"},
}
users, err := client.Users.List().Do(ctx, api.WithBearerToken(token.AccessToken)) return twitch.doAuth(formData)
}
func (twitch *TwitchAuth) DoRefresh(conf *util.Config, refreshToken string) (TwitchAuthTokenResp, error) {
formData := url.Values{
"client_id": {conf.ClientID},
"client_secret": {conf.ClientSecret},
"refresh_token": {refreshToken},
"grant_type": {"refresh_token"},
}
return twitch.doAuth(formData)
}
func (twitch *TwitchAuth) GetTokenUser(accessToken string) (api.User, error) {
return getTokenUser(twitch.Ctx, twitch.Client, accessToken)
}
func getTokenUser(ctx context.Context, client *api.Client, accessToken string) (api.User, error) {
users, err := client.Users.List().Do(ctx, api.WithBearerToken(accessToken))
if err != nil { if err != nil {
return db_cold.UserAuth{}, err return api.User{}, err
} }
usersData := users.Data usersData := users.Data
if len(usersData) <= 0 { if len(usersData) <= 0 {
return db_cold.UserAuth{}, errors.New("user data returned an empty array") return api.User{}, errors.New("user data returned an empty array")
} }
// from twitch // from twitch
mainUser := usersData[0] return usersData[0], nil
token.UserLogin = mainUser.UserLogin
token.UserName = mainUser.UserName
token.UserEmail = mainUser.Email
return token, nil
}
func refreshToken(token db_cold.UserAuth) (db_cold.UserAuth, error) {
// TODO get new access token using refresh token
// TODO this should be called regularly, as needed based on Expires
} }

69
ttv/expiration.go Normal file
View File

@@ -0,0 +1,69 @@
package ttv
import (
"log"
"time"
"zomo.dev/largehadroncollider/db"
"zomo.dev/largehadroncollider/util"
)
const REFRESH_INTERVAL = 5 * time.Minute
// token expiration thread
func (twitch *TwitchAuth) loopUpdateDetailsRefreshTokens(conf *util.Config, dbConn *db.DBConn) {
for {
// sleep until next interval
now := time.Now()
nextTime := now.Truncate(REFRESH_INTERVAL).Add(REFRESH_INTERVAL)
time.Sleep(nextTime.Sub(now))
// check tokens
err := twitch.updateDetailsRefreshTokens(conf, dbConn)
if err != nil {
log.Printf("Error Updating Tokens: %v\n", err)
}
}
}
func (twitch *TwitchAuth) updateDetailsRefreshTokens(conf *util.Config, dbConn *db.DBConn) error {
now := time.Now().Add(REFRESH_INTERVAL)
tokens, err := dbConn.Cold.GetAllUserAuth()
if err != nil {
return err
}
for _, token := range tokens {
if token.TokenExpires.Before(now) {
// refresh and update details
authResp, err := twitch.DoRefresh(conf, token.RefreshToken)
if err != nil {
return err
}
err = twitch.UpdateUserDetails(dbConn, authResp.AccessToken, authResp.RefreshToken, authResp.ExpiresIn)
if err != nil {
return err
}
} else {
// only update details
err = twitch.UpdateUserDetails(dbConn, token.AccessToken, token.RefreshToken, token.TokenExpires)
if err != nil {
return err
}
}
}
return nil
}
/** tokenExpires: time.Time | time.Duration | int */
func (twitch *TwitchAuth) UpdateUserDetails(dbConn *db.DBConn, accessToken, refreshToken string, tokenExpires any) error {
user, err := twitch.GetTokenUser(accessToken)
if err != nil {
return err
}
return dbConn.Cold.UpdateUserAuth(user.UserID, user.UserName, user.UserLogin, accessToken, refreshToken, tokenExpires)
}

View File

@@ -11,9 +11,29 @@ func InitTwitchConn(conf *util.Config, dbConn *db.DBConn) (*TwitchConn, error) {
return nil, err return nil, err
} }
return &TwitchConn{ auth }, nil return &TwitchConn{auth}, nil
} }
type TwitchConn struct { type TwitchConn struct {
Auth *TwitchAuth Auth *TwitchAuth
} }
type TwitchAuthRespOk struct {
Code string `json:"code"`
Scope string `json:"scope"`
State string `json:"state"`
}
type TwitchAuthRespErr struct {
Err string `json:"error"`
ErrDesc string `json:"error_description"`
State string `json:"state"`
}
type TwitchAuthTokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope []string `json:"scope"`
TokenType string `json:"token_type"`
}