From da31733dcc31ce00d5f69f85a3ceefa3ec55be4e Mon Sep 17 00:00:00 2001 From: bagas Date: Tue, 30 Apr 2024 15:16:33 +0700 Subject: [PATCH] Implement Google OAuth2 --- handler/auth/google/callback/callback.go | 182 +++++++++++++++++++++++ handler/auth/google/google.go | 20 +++ handler/signin/signin.go | 4 +- routes/routes.go | 36 ++++- session/session.go | 1 + utils/utils.go | 26 +++- view/signin/signin.templ | 11 ++ 7 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 handler/auth/google/callback/callback.go create mode 100644 handler/auth/google/google.go diff --git a/handler/auth/google/callback/callback.go b/handler/auth/google/callback/callback.go new file mode 100644 index 0000000..962cbea --- /dev/null +++ b/handler/auth/google/callback/callback.go @@ -0,0 +1,182 @@ +package googleOauthCallbackHandler + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/fossyy/filekeeper/cache" + "github.com/fossyy/filekeeper/db" + signinHandler "github.com/fossyy/filekeeper/handler/signin" + "github.com/fossyy/filekeeper/logger" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + "net/http" + "net/url" + "sync" + "time" +) + +//type OauthToken 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"` +// IdToken string `json:"id_token"` +//} +// +//type OauthUser struct { +// Id string `json:"id"` +// Email string `json:"email"` +// VerifiedEmail bool `json:"verified_email"` +// Name string `json:"name"` +// GivenName string `json:"given_name"` +// Picture string `json:"picture"` +// Locale string `json:"locale"` +//} + +type OauthToken struct { + AccessToken string `json:"access_token"` +} + +type OauthUser struct { + Email string `json:"email"` + VerifiedEmail bool `json:"verified_email"` +} + +type CsrfToken struct { + Token string + CreateTime time.Time + mu sync.Mutex +} + +var log *logger.AggregatedLogger +var CsrfTokens map[string]*CsrfToken + +func init() { + log = logger.Logger() + CsrfTokens = make(map[string]*CsrfToken) + + ticker := time.NewTicker(time.Minute) + go func() { + for { + <-ticker.C + currentTime := time.Now() + cacheClean := 0 + cleanID := utils.GenerateRandomString(10) + log.Info(fmt.Sprintf("Cache cleanup [csrf_token] [%s] initiated at %02d:%02d:%02d", cleanID, currentTime.Hour(), currentTime.Minute(), currentTime.Second())) + + for _, data := range CsrfTokens { + data.mu.Lock() + if currentTime.Sub(data.CreateTime) > time.Minute-10 { + delete(CsrfTokens, data.Token) + cacheClean++ + } + data.mu.Unlock() + } + + log.Info(fmt.Sprintf("Cache cleanup [csrf_token] [%s] completed: %d entries removed. Finished at %s", cleanID, cacheClean, time.Since(currentTime))) + } + }() +} + +func GET(w http.ResponseWriter, r *http.Request) { + if _, ok := CsrfTokens[r.URL.Query().Get("state")]; !ok { + http.Error(w, "csrf token mismatch", http.StatusInternalServerError) + return + } + + delete(CsrfTokens, r.URL.Query().Get("state")) + + formData := url.Values{ + "grant_type": {"authorization_code"}, + "code": {r.URL.Query().Get("code")}, + "client_id": {utils.Getenv("GOOGLE_CLIENT_ID")}, + "client_secret": {utils.Getenv("GOOGLE_CLIENT_SECRET")}, + "redirect_uri": {utils.Getenv("GOOGLE_CALLBACK")}, + } + resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", bytes.NewBufferString(formData.Encode())) + if err != nil { + log.Error("Error:", err) + http.Error(w, "Failed to get token", http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + var oauthData OauthToken + if err := json.NewDecoder(resp.Body).Decode(&oauthData); err != nil { + log.Error("Error reading token response body:", err) + http.Error(w, "Failed to read token response body", http.StatusInternalServerError) + return + } + + client := &http.Client{} + + req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil) + req.Header.Set("Authorization", "Bearer "+oauthData.AccessToken) + if err != nil { + log.Error("Error creating user info request:", err) + http.Error(w, "Failed to create user info request", http.StatusInternalServerError) + return + } + + userInfoResp, err := client.Do(req) + defer userInfoResp.Body.Close() + + var oauthUser OauthUser + if err := json.NewDecoder(userInfoResp.Body).Decode(&oauthUser); err != nil { + log.Error("Error reading user info response body:", err) + http.Error(w, "Failed to read user info response body", http.StatusInternalServerError) + return + } + + if !db.DB.IsUserRegistered(oauthUser.Email, "ll") { + http.Redirect(w, r, "/signup", http.StatusSeeOther) + return + } + + user, err := cache.GetUser(oauthUser.Email) + + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + log.Error(err.Error()) + return + } + storeSession := session.GlobalSessionStore.Create() + storeSession.Values["user"] = types.User{ + UserID: user.UserID, + Email: oauthUser.Email, + Username: user.Username, + Authenticated: true, + } + + userAgent := r.Header.Get("User-Agent") + browserInfo, osInfo := signinHandler.ParseUserAgent(userAgent) + + sessionInfo := session.SessionInfo{ + SessionID: storeSession.ID, + Browser: browserInfo["browser"], + Version: browserInfo["version"], + OS: osInfo["os"], + OSVersion: osInfo["version"], + IP: utils.ClientIP(r), + Location: "Indonesia", + } + + storeSession.Save(w) + session.AddSessionInfo(oauthUser.Email, &sessionInfo) + + cookie, err := r.Cookie("redirect") + if errors.Is(err, http.ErrNoCookie) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "redirect", + MaxAge: -1, + }) + http.Redirect(w, r, cookie.Value, http.StatusSeeOther) + return +} diff --git a/handler/auth/google/google.go b/handler/auth/google/google.go new file mode 100644 index 0000000..87ab3fb --- /dev/null +++ b/handler/auth/google/google.go @@ -0,0 +1,20 @@ +package googleOauthHandler + +import ( + "fmt" + googleOauthCallbackHandler "github.com/fossyy/filekeeper/handler/auth/google/callback" + "github.com/fossyy/filekeeper/utils" + "net/http" + "time" +) + +func GET(w http.ResponseWriter, r *http.Request) { + token, err := utils.GenerateCSRFToken() + googleOauthCallbackHandler.CsrfTokens[token] = &googleOauthCallbackHandler.CsrfToken{Token: token, CreateTime: time.Now()} + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, fmt.Sprintf("https://accounts.google.com/o/oauth2/auth?scope=email profile&response_type=code&access_type=offline&state=%s&redirect_uri=%s/auth/google/callback&client_id=%s", token, "http://localhost:8000", "324904877864-vrbqof7nea1l89316d26sk0s76105hc4.apps.googleusercontent.com"), http.StatusFound) + return +} diff --git a/handler/signin/signin.go b/handler/signin/signin.go index 9a090a3..2d410b0 100644 --- a/handler/signin/signin.go +++ b/handler/signin/signin.go @@ -67,7 +67,7 @@ func POST(w http.ResponseWriter, r *http.Request) { } userAgent := r.Header.Get("User-Agent") - browserInfo, osInfo := parseUserAgent(userAgent) + browserInfo, osInfo := ParseUserAgent(userAgent) sessionInfo := session.SessionInfo{ SessionID: storeSession.ID, @@ -106,7 +106,7 @@ func POST(w http.ResponseWriter, r *http.Request) { } } -func parseUserAgent(userAgent string) (map[string]string, map[string]string) { +func ParseUserAgent(userAgent string) (map[string]string, map[string]string) { browserInfo := make(map[string]string) osInfo := make(map[string]string) if strings.Contains(userAgent, "Firefox") { diff --git a/routes/routes.go b/routes/routes.go index 0b9b1ab..3b23ac7 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,8 +1,8 @@ package routes import ( - "net/http" - + googleOauthHandler "github.com/fossyy/filekeeper/handler/auth/google" + googleOauthCallbackHandler "github.com/fossyy/filekeeper/handler/auth/google/callback" downloadHandler "github.com/fossyy/filekeeper/handler/download" downloadFileHandler "github.com/fossyy/filekeeper/handler/download/file" forgotPasswordHandler "github.com/fossyy/filekeeper/handler/forgotPassword" @@ -17,6 +17,7 @@ import ( "github.com/fossyy/filekeeper/handler/upload/initialisation" userHandler "github.com/fossyy/filekeeper/handler/user" "github.com/fossyy/filekeeper/middleware" + "net/http" ) func SetupRoutes() *http.ServeMux { @@ -36,6 +37,37 @@ func SetupRoutes() *http.ServeMux { } }) + authRouter := http.NewServeMux() + handler.Handle("/auth/", http.StripPrefix("/auth", authRouter)) + authRouter.HandleFunc("/google", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(googleOauthHandler.GET, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + + }) + + authRouter.HandleFunc("/google/callback", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(googleOauthCallbackHandler.GET, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + //TODO make a setup route for unregistered google oauth user + //authRouter.HandleFunc("/google/setup", func(w http.ResponseWriter, r *http.Request) { + // switch r.Method { + // case http.MethodGet: + // middleware.Guest(googleOauthSetupHandler.GET, w, r) + // default: + // http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + // } + //}) + handler.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/session/session.go b/session/session.go index 5f37fbe..73b7b8d 100644 --- a/session/session.go +++ b/session/session.go @@ -72,6 +72,7 @@ func (s *Session) Save(w http.ResponseWriter) { Name: utils.Getenv("SESSION_NAME"), Value: s.ID, MaxAge: maxAge, + Path: "/", }) } diff --git a/utils/utils.go b/utils/utils.go index 4979cdc..131dae7 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,8 +1,11 @@ package utils import ( + cryptoRand "crypto/rand" + "crypto/sha1" + "encoding/base64" "fmt" - "math/rand" + mathRand "math/rand" "net/http" "os" "strings" @@ -23,6 +26,10 @@ type Env struct { var env *Env var log *logger.AggregatedLogger +const ( + csrfTokenLength = 32 // Length of the CSRF token in bytes +) + func init() { env = &Env{value: map[string]string{}} } @@ -124,7 +131,7 @@ func Getenv(key string) string { func GenerateRandomString(length int) string { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - seededRand := rand.New(rand.NewSource(time.Now().UnixNano() + int64(rand.Intn(9999)))) + seededRand := mathRand.New(mathRand.NewSource(time.Now().UnixNano() + int64(mathRand.Intn(9999)))) var result strings.Builder for i := 0; i < length; i++ { randomIndex := seededRand.Intn(len(charset)) @@ -133,6 +140,21 @@ func GenerateRandomString(length int) string { return result.String() } +func GenerateCSRFToken() (string, error) { + tokenBytes := make([]byte, 32) + _, err := cryptoRand.Read(tokenBytes) + if err != nil { + return "", err + } + hash := sha1.New() + hash.Write(tokenBytes) + hashedToken := hash.Sum(nil) + + csrfToken := base64.URLEncoding.EncodeToString(hashedToken) + + return csrfToken, nil +} + func SanitizeFilename(filename string) string { invalidChars := []string{"\\", "/", ":", "*", "?", "\"", "<", ">", "|"} diff --git a/view/signin/signin.templ b/view/signin/signin.templ index ef16bbb..4c6e16e 100644 --- a/view/signin/signin.templ +++ b/view/signin/signin.templ @@ -39,6 +39,17 @@ templ content(err types.Message, title string) { Login +
+

+ OR +

+
+ + Google-color Created with Sketch. + Continue with Google +
Don't have an account?