diff options
Diffstat (limited to '')
| -rw-r--r-- | internal/auth/handler.go | 105 | ||||
| -rw-r--r-- | internal/auth/handler_test.go | 60 | ||||
| -rw-r--r-- | internal/auth/model.go | 49 | ||||
| -rw-r--r-- | internal/auth/repo.go | 8 | ||||
| -rw-r--r-- | internal/auth/service.go | 97 | ||||
| -rw-r--r-- | internal/auth/service_test.go | 133 |
6 files changed, 452 insertions, 0 deletions
diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..992608c --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,105 @@ +package auth + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + + "github.com/alexedwards/scs/v2" + "golang.org/x/oauth2" +) + +var ( + profileURL = "https://www.googleapis.com/oauth2/v3/userinfo" +) + +// Handler authentication handler +// handles, login, oauth and registration +type Handler interface { + Login(w http.ResponseWriter, r *http.Request) + OauthCallback(w http.ResponseWriter, r *http.Request) +} + +type authHandler struct { + service Servicer + ses *scs.SessionManager + oclient *oauth2.Config +} + +// NewHandler create new instance of handler +// Servicer, session, and oauth client required +func NewHandler(service Servicer, + session *scs.SessionManager, + oclient *oauth2.Config) Handler { + return &authHandler{ + service, + session, + oclient, + } +} + +func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { + stateValue, err := h.service.GenerateStateToken() + if err != nil { + return + } + h.ses.Put(r.Context(), "state", stateValue) + url := h.oclient.AuthCodeURL(stateValue, oauth2.AccessTypeOnline) + http.Redirect(w, r, url, 302) +} + +func (h *authHandler) OauthCallback(w http.ResponseWriter, r *http.Request) { + // this needs to be random + stateFromSession := h.ses.GetString(r.Context(), "state") + stateFromCallback := r.FormValue("state") + // prevent arbitrary authorization exchanges + if stateFromCallback != stateFromSession { + http.Error(w, "state value miss match bad data", 400) + return + } + stateValid, err := h.service.ValidateStateToken(stateFromCallback, stateFromSession) + if err != nil { + http.Error(w, "error validating", 400) + return + } + if !stateValid { + http.Error(w, "invalid tokens", 400) + return + } + // valid and same as state + code := r.FormValue("code") + token, err := h.oclient.Exchange(r.Context(), code) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + log.Printf("returned token %v", token) + // google profile + client := h.oclient.Client(r.Context(), token) + resp, err := client.Get(profileURL) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + gp := &GoogleAuthProfile{} + err = json.Unmarshal(data, &gp) + profileID, newProfile, err := h.service.LoginOrRegisterSessionID(token, gp) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + h.ses.Put(r.Context(), "profid", profileID) + // send to registration + if newProfile == true { + http.Redirect(w, r, "/account/register", 302) + return + } + http.Redirect(w, r, "/", 302) +} diff --git a/internal/auth/handler_test.go b/internal/auth/handler_test.go new file mode 100644 index 0000000..abf82a8 --- /dev/null +++ b/internal/auth/handler_test.go @@ -0,0 +1,60 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/alexedwards/scs/v2" + _ "github.com/brianvoe/gofakeit" + "github.com/golang/mock/gomock" + + "git.ofmax.li/iserv/internal/auth" + "git.ofmax.li/iserv/internal/mock/mock_auth" + testhelper "git.ofmax.li/iserv/internal/test" +) + +type handlerSuite struct { + as *mock_auth.MockServicer + ses *scs.SessionManager +} + +func TestHandler(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + session := scs.New() + ts := &handlerSuite{ + as: mock_auth.NewMockServicer(ctrl), + ses: session, + } + ah := auth.NewHandler(ts.as, session, testhelper.NewConf("testhost")) + t.Run("test login", ts.testLogin(ah)) +} + +func (s *handlerSuite) testLogin(ah auth.Handler) func(t *testing.T) { + return func(t *testing.T) { + s.as.EXPECT().GenerateStateToken().Return("asfdas", nil) + r, _ := http.NewRequest("GET", "/auth/login/", nil) + w := httptest.NewRecorder() + handler := s.ses.LoadAndSave(http.HandlerFunc(ah.Login)) + handler.ServeHTTP(w, r) + response := w.Result() + if response.StatusCode != http.StatusFound { + t.Errorf("login http status not 302") + } + redirectURL, _ := response.Location() + if !strings.Contains(redirectURL.String(), "REDIRECT_URL") { + t.Errorf("redirect url not foukd %s", redirectURL) + } + if !strings.Contains(redirectURL.String(), "asfdas") { + t.Errorf("state token not found in url") + } + } +} + +func (s *handlerSuite) testCallBack(ah auth.Handler) func(t *testing.T) { + return func(t *testing.T) { + t.Logf("not doing this at the moment") + } +} diff --git a/internal/auth/model.go b/internal/auth/model.go new file mode 100644 index 0000000..c51ff05 --- /dev/null +++ b/internal/auth/model.go @@ -0,0 +1,49 @@ +package auth + +import ( + "time" + + "golang.org/x/oauth2" +) + +// GoogleAuthProfile auth'd user profile +// ProfileId, Email, Name, PictureURL +type GoogleAuthProfile struct { + ProfileID string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + PictureURL string `json:"picture"` +} + +// Profile profile and token +// Identifier + Token +type Profile struct { + ID string `json:"sub"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` +} + +// Token token from profile +func (ap *Profile) Token() (*oauth2.Token, error) { + return &oauth2.Token{ + AccessToken: ap.AccessToken, + TokenType: ap.TokenType, + RefreshToken: ap.RefreshToken, + Expiry: ap.Expiry, + }, nil +} + +// NewAuthProfile merge token and profile +func NewAuthProfile(t *oauth2.Token, u *GoogleAuthProfile) *Profile { + return &Profile{ + u.ProfileID, + u.Email, + t.AccessToken, + t.TokenType, + t.RefreshToken, + t.Expiry, + } +} diff --git a/internal/auth/repo.go b/internal/auth/repo.go new file mode 100644 index 0000000..f404c94 --- /dev/null +++ b/internal/auth/repo.go @@ -0,0 +1,8 @@ +package auth + +// Repo storage interface +type Repo interface { + IsAuthorized(gp *GoogleAuthProfile) (bool, error) + LookUpAuthProfileID(gp *GoogleAuthProfile) (string, error) + SaveAuthProfile(ap *Profile) error +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..9997264 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,97 @@ +package auth + +import ( + "errors" + "log" + "time" + + "golang.org/x/oauth2" + + "github.com/gbrlsnchs/jwt/v3" +) + +var ( + singingSecret = jwt.NewHS512([]byte("the wolf says moo")) + // ErrInvalidToken error for token + ErrInvalidToken = errors.New("Invalid token provided") + // ErrInvalidJWT error for jwt + ErrInvalidJWT = errors.New("Invalid JWT") + // ErrUnauthorized error for unauthorized access + ErrUnauthorized = errors.New("Unauthorized") +) + +// Servicer access to auth functionality +type Servicer interface { + LoginOrRegisterSessionID(t *oauth2.Token, gp *GoogleAuthProfile) (string, bool, error) + GenerateStateToken() (string, error) + ValidateStateToken(token string, sessionToken string) (bool, error) +} + +// Service a container for auth deps +type Service struct { + repo Repo +} + +// NewService create auth service +func NewService(repo Repo) *Service { + return &Service{ + repo, + } +} + +// GenerateStateToken create a random token for oauth exchange +func (a *Service) GenerateStateToken() (string, error) { + now := time.Now() + pl := jwt.Payload{ + Issuer: "iserv-state", + Subject: "state param", + IssuedAt: jwt.NumericDate(now), + } + tokenBytes, err := jwt.Sign(pl, singingSecret) + if err != nil { + return "", err + } + return string(tokenBytes[:]), err +} + +// ValidateStateToken validate provided token +func (a *Service) ValidateStateToken(token string, sessionToken string) (bool, error) { + if token == sessionToken { + p := jwt.Payload{} + _, err := jwt.Verify([]byte(token), singingSecret, &p) + if err != nil { + return false, ErrInvalidJWT + } + return true, nil + } + return false, ErrInvalidToken +} + +// LoginOrRegisterSessionID create a login +func (a *Service) LoginOrRegisterSessionID(t *oauth2.Token, gp *GoogleAuthProfile) (string, bool, error) { + isAuthorized, err := a.repo.IsAuthorized(gp) + newRegistration := false + if err != nil { + return "", newRegistration, err + } + if isAuthorized != true { + return "", newRegistration, ErrUnauthorized + } + profileID, err := a.repo.LookUpAuthProfileID(gp) + if err != nil { + return "", newRegistration, err + } + if profileID == "" { + // create profile + log.Printf("creating new profile") + profile := NewAuthProfile(t, gp) + profileID = profile.ID + log.Printf("new profile %+v", profile) + err = a.repo.SaveAuthProfile(profile) + if err != nil { + return "", newRegistration, err + } + newRegistration = true + } + return profileID, newRegistration, nil +} diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go new file mode 100644 index 0000000..72ff709 --- /dev/null +++ b/internal/auth/service_test.go @@ -0,0 +1,133 @@ +package auth_test + +import ( + "errors" + "strings" + "testing" + + "golang.org/x/oauth2" + + "github.com/brianvoe/gofakeit" + "github.com/golang/mock/gomock" + + "git.ofmax.li/iserv/internal/auth" + "git.ofmax.li/iserv/internal/mock/mock_auth" +) + +type serviceSuite struct { + as auth.Servicer + repo *mock_auth.MockRepo +} + +func TestServices(t *testing.T) { + ctrl := gomock.NewController(t) + repo := mock_auth.NewMockRepo(ctrl) + defer ctrl.Finish() + ts := &serviceSuite{ + auth.NewService(repo), + repo, + } + t.Run("token generation", ts.testGenStateToken()) + t.Run("token validation", ts.testValidateStateToken()) + t.Run("auth profile registration", ts.testLoginOrRegsiterSessionId()) +} + +func (s *serviceSuite) testGenStateToken() func(t *testing.T) { + return func(t *testing.T) { + token, _ := s.as.GenerateStateToken() + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Errorf("token doesn't match format") + } + } +} + +func (s *serviceSuite) testValidateStateToken() func(t *testing.T) { + validToken, _ := s.as.GenerateStateToken() + return func(t *testing.T) { + cases := []struct { + name string + token string + sessionToken string + wanted bool + errz error + }{ + {name: "valid matching token", + token: validToken, + sessionToken: validToken, + wanted: true, errz: nil}, + {name: "non matching token", + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + sessionToken: "sadfsaf", + wanted: false, errz: auth.ErrInvalidToken}, + {name: "matching but not real", + token: "eyJhbGciOTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + sessionToken: "eyJhbGciOTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + wanted: false, errz: auth.ErrInvalidJWT}, + } + for _, tc := range cases { + isValid, err := s.as.ValidateStateToken(tc.token, tc.sessionToken) + if (isValid != tc.wanted) || (err != tc.errz) { + t.Fatalf("%s: expected: %v, got: %v err: %v errgot: %v", tc.name, tc.wanted, isValid, tc.errz, err) + } + } + } +} + +func (s *serviceSuite) testLoginOrRegsiterSessionId() func(t *testing.T) { + return func(t *testing.T) { + // is authorized err + gp := &auth.GoogleAuthProfile{} + token := &oauth2.Token{} + gofakeit.Struct(token) + gofakeit.Struct(gp) + s.repo. + EXPECT(). + IsAuthorized(gomock.Any()). + Return(false, errors.New("foo")) + id, isNew, err := s.as.LoginOrRegisterSessionID(token, gp) + if err == nil && id == "" && isNew == false { + t.Fatalf("%s", id) + } + // not authorized no error + s.repo. + EXPECT(). + IsAuthorized(gomock.Any()). + Return(false, nil) + id, isNew, err = s.as.LoginOrRegisterSessionID(token, gp) + if err != auth.ErrUnauthorized || id != "" || isNew != false { + t.Fatalf("unauthorized isnew: %v id: %v err: %v", isNew, id, err.Error()) + } + + // authorized + s.repo. + EXPECT(). + IsAuthorized(gomock.Any()). + Return(true, nil) + s.repo. + EXPECT(). + LookUpAuthProfileID(gomock.Any()). + Return("asdfsafaf", nil) + id, isNew, err = s.as.LoginOrRegisterSessionID(token, gp) + if err != nil && isNew == false && id == "" { + t.Fatalf("auth profile exists isnew: %v id: %v err: %v", isNew, id, err.Error()) + } + // new registration + s.repo. + EXPECT(). + IsAuthorized(gomock.Any()). + Return(true, nil) + s.repo. + EXPECT(). + LookUpAuthProfileID(gomock.Any()). + Return("", nil) + s.repo. + EXPECT(). + SaveAuthProfile(gomock.Any()). + Return(nil) + id, isNew, err = s.as.LoginOrRegisterSessionID(token, gp) + if err != nil && isNew == false && id == "" { + t.Fatalf("isnew: %v id: %v err: %v", isNew, id, err.Error()) + } + } +} |