diff --git a/api/endpoints.go b/api/endpoints.go index d1de48c..cf53b3c 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -1,10 +1,12 @@ package api import ( + "log" "net/http" "net/url" "github.com/gin-gonic/gin" + "zomo.dev/largehadroncollider/ttv" ) func (server *ApiServer) loadEndpoints() { @@ -18,9 +20,9 @@ func (server *ApiServer) loadEndpoints() { serverInfo := ServerInfo{ ClientID: server.conf.ClientID, AuthParams: TwitchAuthParams{ - ClientID: server.conf.ClientID, - ForceVerify: false, - RedirectURI: server.conf.RedirectURI, + ClientID: server.conf.ClientID, + ForceVerify: false, + RedirectURI: server.conf.RedirectURI, ResponseType: "code", Scope: []string{ "bits:read", @@ -51,46 +53,62 @@ func (server *ApiServer) loadEndpoints() { server.engine.GET("/auth", func(c *gin.Context) { q := c.Request.URL.Query() - if resp := loadAuthQueryOk(q); resp != nil { + if reqBody := loadAuthQueryOk(q); reqBody != nil { // ok - // TODO check state (need state system) - // TODO POST https://id.twitch.tv/oauth2/token - returns TwitchAuthTokenResp - // convert expiresIn to time.Time (minus like 15 minutes as a buffer period) - // UpdateUserAuth() - // TODO return twitch ok (or err if can't POST) - } else if resp := loadAuthQueryErr(q); resp != nil { + + // TODO check state value (need state system) + + authResp, err := server.twitch.Auth.DoAuth(server.conf, reqBody.Code) + if err != 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 // TODO check state (need state system) // TODO return twitch err + c.AbortWithStatus(http.StatusBadRequest) } else { // err in params // 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) }) } type ServerInfo struct { - ClientID string `json:"client_id"` + ClientID string `json:"client_id"` AuthParams TwitchAuthParams `json:"auth_params"` } type TwitchAuthParams struct { - ClientID string `json:"client_id"` - ForceVerify bool `json:"force_verify"` - RedirectURI string `json:"redirect_uri"` - ResponseType string `json:"response_type"` - Scope []string `json:"scope"` - State string `json:"state"` + ClientID string `json:"client_id"` + ForceVerify bool `json:"force_verify"` + RedirectURI string `json:"redirect_uri"` + ResponseType string `json:"response_type"` + Scope []string `json:"scope"` + 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") { - return &TwitchAuthRespOk{ + return &ttv.TwitchAuthRespOk{ Code: query.Get("code"), Scope: query.Get("scope"), State: query.Get("state"), @@ -99,9 +117,9 @@ func loadAuthQueryOk(query url.Values) *TwitchAuthRespOk { 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") { - return &TwitchAuthRespErr{ + return &ttv.TwitchAuthRespErr{ Err: query.Get("error"), ErrDesc: query.Get("error_description"), State: query.Get("state"), @@ -109,23 +127,3 @@ func loadAuthQueryErr(query url.Values) *TwitchAuthRespErr { } 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"` -} diff --git a/api/main.go b/api/main.go index 60659d3..df54eb8 100644 --- a/api/main.go +++ b/api/main.go @@ -10,17 +10,17 @@ import ( func InitApiServer(conf *util.Config, dbConn *db.DBConn, twitchConn *ttv.TwitchConn) (*ApiServer, error) { engine := gin.Default() - apiServer := &ApiServer{ engine, conf, dbConn, twitchConn } + apiServer := &ApiServer{engine, conf, dbConn, twitchConn} apiServer.loadEndpoints() - + return apiServer, nil } type ApiServer struct { engine *gin.Engine - conf *util.Config - db *db.DBConn + conf *util.Config + db *db.DBConn twitch *ttv.TwitchConn } diff --git a/db/db_cold/authTokens.go b/db/db_cold/authTokens.go index eabc669..2e2f15d 100644 --- a/db/db_cold/authTokens.go +++ b/db/db_cold/authTokens.go @@ -1,11 +1,15 @@ package db_cold import ( + "log" + "reflect" "time" "gorm.io/gorm" ) +const EXPIRATION_BUFFER = 5 * time.Minute + type UserAuth struct { gorm.Model UserID string `gorm:"primarykey"` @@ -31,12 +35,12 @@ func (db *DBColdConn) GetAllUserAuth() ([]UserAuth, error) { } // 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{ - UserID: userID, - UserName: userName, - UserLogin: userLogin, - AccessToken: accessToken, + UserID: userID, + UserName: userName, + UserLogin: userLogin, + AccessToken: accessToken, RefreshToken: refreshToken, TokenExpires: tokenExpires, } @@ -62,3 +66,36 @@ func (db *DBColdConn) UpdateUserAuth(userID, userName, userLogin, accessToken, r 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") + } +} diff --git a/db/db_cold/main.go b/db/db_cold/main.go index 7a0b251..7bba4e5 100644 --- a/db/db_cold/main.go +++ b/db/db_cold/main.go @@ -16,8 +16,8 @@ func InitDBColdConn(conf *util.Config) (*DBColdConn, error) { return nil, err } ctx := context.Background() - - cold := &DBColdConn{ db, ctx } + + cold := &DBColdConn{db, ctx} cold.initUserAuth() return cold, nil @@ -25,5 +25,5 @@ func InitDBColdConn(conf *util.Config) (*DBColdConn, error) { type DBColdConn struct { Gorm *gorm.DB - Ctx context.Context + Ctx context.Context } diff --git a/db/main.go b/db/main.go index e4752f3..7b5b042 100644 --- a/db/main.go +++ b/db/main.go @@ -15,10 +15,10 @@ func InitDBConn(conf *util.Config) (*DBConn, error) { if err != nil { return nil, err } - return &DBConn{ hot, cold }, nil + return &DBConn{hot, cold}, nil } type DBConn struct { - Hot *db_hot.DBHotConn + Hot *db_hot.DBHotConn Cold *db_cold.DBColdConn } diff --git a/test/main.go b/test/main.go new file mode 100644 index 0000000..af5abb3 --- /dev/null +++ b/test/main.go @@ -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)) +} diff --git a/ttv/auth.go b/ttv/auth.go index e00461d..724558f 100644 --- a/ttv/auth.go +++ b/ttv/auth.go @@ -2,83 +2,96 @@ package ttv import ( "context" + "encoding/json" "errors" + "io" + "net/http" + "net/url" "github.com/adeithe/go-twitch/api" "zomo.dev/largehadroncollider/db" - "zomo.dev/largehadroncollider/db/db_cold" "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) { ctx := context.Background() - - tokens, err := dbConn.Cold.GetAllUserAuth() - if err != nil { - return nil, err - } - client := api.New(conf.ClientID) + twitchAuth := &TwitchAuth{ctx, client} - accounts, err := testTokens(ctx, client, tokens) - if err != nil { - return nil, err - } + // run once synchronously then start looping in a thread + twitchAuth.updateDetailsRefreshTokens(conf, dbConn) + go twitchAuth.loopUpdateDetailsRefreshTokens(conf, dbConn) - for _, account := range accounts { - 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 + return twitchAuth, nil } type TwitchAuth struct { - Ctx context.Context + Ctx context.Context Client *api.Client - Accounts []db_cold.UserAuth } -func testTokens(ctx context.Context, client *api.Client, tokens []db_cold.UserAuth) ([]db_cold.UserAuth, error) { - accounts := make([]db_cold.UserAuth, 0) - for _, token := range tokens { - account, err := testToken(ctx, client, token) - if err != nil { - return nil, err - } - accounts = append(accounts, account) - } - return accounts, nil -} - -func testToken(ctx context.Context, client *api.Client, token db_cold.UserAuth) (db_cold.UserAuth, error) { - // TODO check refresh time, refresh token if needed - - users, err := client.Users.List().Do(ctx, api.WithBearerToken(token.AccessToken)) +func (twitch *TwitchAuth) doAuth(formData url.Values) (TwitchAuthTokenResp, error) { + resp, err := http.PostForm(TWITCH_AUTH_URL, formData) if err != nil { - return db_cold.UserAuth{}, err + return TwitchAuthTokenResp{}, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return TwitchAuthTokenResp{}, err + } + + var authResp TwitchAuthTokenResp + err = json.Unmarshal(bodyBytes, &authResp) + if err != nil { + return TwitchAuthTokenResp{}, err + } + + return authResp, nil +} + +func (twitch *TwitchAuth) DoAuth(conf *util.Config, code string) (TwitchAuthTokenResp, error) { + formData := url.Values{ + "client_id": {conf.ClientID}, + "client_secret": {conf.ClientSecret}, + "redirect_uri": {conf.RedirectURI}, + "code": {code}, + "grant_type": {"authorization_code"}, + } + + 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 { + return api.User{}, err } usersData := users.Data 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 - mainUser := usersData[0] - 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 + return usersData[0], nil } diff --git a/ttv/expiration.go b/ttv/expiration.go new file mode 100644 index 0000000..0a0c14d --- /dev/null +++ b/ttv/expiration.go @@ -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) +} diff --git a/ttv/main.go b/ttv/main.go index 99a13ef..3115ad0 100644 --- a/ttv/main.go +++ b/ttv/main.go @@ -11,9 +11,29 @@ func InitTwitchConn(conf *util.Config, dbConn *db.DBConn) (*TwitchConn, error) { return nil, err } - return &TwitchConn{ auth }, nil + return &TwitchConn{auth}, nil } type TwitchConn struct { 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"` +}