aboutsummaryrefslogtreecommitdiff
path: root/internal/auth
diff options
context:
space:
mode:
authorMax Resnick <max@ofmax.li>2020-08-14 23:13:41 -0700
committerMax Resnick <max@ofmax.li>2020-11-08 07:57:13 -0800
commit689a57ec4a444f8233fe2e5ec7ceb0903218218d (patch)
tree1bcfe6786c38b4ae11997d5d97dc3c5fba747b97 /internal/auth
parent77c2e6aca2dc0f851f55e30a0f49c9ee7c2c952e (diff)
downloadiserv-master.tar.gz
feat: working login gauthHEADmaster
Diffstat (limited to 'internal/auth')
-rw-r--r--internal/auth/handler.go105
-rw-r--r--internal/auth/handler_test.go60
-rw-r--r--internal/auth/model.go49
-rw-r--r--internal/auth/repo.go8
-rw-r--r--internal/auth/service.go97
-rw-r--r--internal/auth/service_test.go133
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())
+ }
+ }
+}