diff --git a/README.md b/README.md index ea8484e..0910d83 100644 --- a/README.md +++ b/README.md @@ -140,4 +140,7 @@ Next changes sorted by priority - [ ] Add ability to track the visitors (Referrer, maybe also live) - [ ] Create Makefile for building everything - [ ] Test docker-compose installation -- [ ] Provide image on the docker hub \ No newline at end of file +- [ ] Provide image on the docker hub + + +https://console.cloud.google.com/ \ No newline at end of file diff --git a/config/config.go b/config/config.go index bfda44c..97e37e1 100644 --- a/config/config.go +++ b/config/config.go @@ -25,7 +25,9 @@ type Store struct { // Handlers contains the needed fields for the Handlers package type Handlers struct { ListenAddr string + BaseURL string EnableGinDebugMode bool + Secret []byte OAuth struct { Google struct { ClientID string @@ -34,20 +36,61 @@ type Handlers struct { } } +// config holds the temporary loaded data for the +// singelton Get() method +var config *Configuration + +var configPath string + // Get returns the configuration from a given file -func Get() (*Configuration, error) { - var config *Configuration - ex, err := os.Executable() +func Get() *Configuration { + return config +} + +// Preload loads the configuration file into the memory for further usage +func Preload() error { + var err error + configPath, err = getConfigPath() if err != nil { - return nil, errors.Wrap(err, "could not get executable path") + return errors.Wrap(err, "could not get configuration path") } - file, err := ioutil.ReadFile(filepath.Join(filepath.Dir(ex), "config.json")) + err = updateConfig() if err != nil { - return nil, errors.Wrap(err, "could not read configuration file") + return errors.Wrap(err, "could not update config") + } + return nil +} + +func updateConfig() error { + file, err := ioutil.ReadFile(configPath) + if err != nil { + return errors.Wrap(err, "could not read configuration file") } err = json.Unmarshal(file, &config) if err != nil { - return nil, errors.Wrap(err, "could not unmarshal configuration file") + return errors.Wrap(err, "could not unmarshal configuration file") + } + return nil +} + +func getConfigPath() (string, error) { + ex, err := os.Executable() + if err != nil { + return "", errors.Wrap(err, "could not get executable path") + } + return filepath.Join(filepath.Dir(ex), "config.json"), nil +} + +// Set replaces the current configuration with the given one +func Set(conf *Configuration) error { + data, err := json.MarshalIndent(conf, "", " ") + if err != nil { + return err + } + err = ioutil.WriteFile(configPath, data, 0644) + if err != nil { + return err } - return config, nil + config = conf + return nil } diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..476ceba --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,139 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/gin-gonic/contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +type jwtClaims struct { + jwt.StandardClaims + OAuthProvider string + OAuthID string +} + +type oAuthUser struct { + Sub string `json:"sub"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Profile string `json:"profile"` + Picture string `json:"picture"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Gender string `json:"gender"` + Hd string `json:"hd"` +} + +func (h *Handler) initOAuth() { + store := sessions.NewCookieStore([]byte("secret")) + + h.oAuthConf = &oauth2.Config{ + ClientID: h.config.OAuth.Google.ClientID, + ClientSecret: h.config.OAuth.Google.ClientSecret, + RedirectURL: h.config.BaseURL + "/api/v1/callback", + Scopes: []string{ + "https://www.googleapis.com/auth/userinfo.email", + }, + Endpoint: google.Endpoint, + } + h.engine.Use(sessions.Sessions("backend", store)) + h.engine.GET("/api/v1/login", h.handleGoogleLogin) + h.engine.GET("/api/v1/callback", h.handleGoogleCallback) + h.engine.POST("/api/v1/check", h.handleGoogleCheck) +} + +func (h *Handler) handleGoogleLogin(c *gin.Context) { + state := h.randToken() + session := sessions.Default(c) + session.Set("state", state) + session.Save() + c.Redirect(http.StatusTemporaryRedirect, h.oAuthConf.AuthCodeURL(state)) +} + +func (h *Handler) handleGoogleCheck(c *gin.Context) { + var data struct { + Token string `binding:"required"` + } + err := c.ShouldBind(&data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // to the callback, providing flexibility. + token, err := jwt.Parse(data.Token, func(token *jwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return h.config.Secret, nil + }) + if claims, ok := token.Claims.(jwtClaims); ok && token.Valid { + fmt.Println(claims.OAuthID, claims.OAuthProvider) + c.JSON(http.StatusOK, claims) + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + } +} + +func (h *Handler) handleGoogleCallback(c *gin.Context) { + session := sessions.Default(c) + retrievedState := session.Get("state") + if retrievedState != c.Query("state") { + c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Invalid session state: %s", retrievedState)}) + return + } + + oAuthToken, err := h.oAuthConf.Exchange(oauth2.NoContext, c.Query("code")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + client := h.oAuthConf.Client(oauth2.NoContext, oAuthToken) + userinfo, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + defer userinfo.Body.Close() + data, err := ioutil.ReadAll(userinfo.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Could not read body: %v", err)}) + return + } + + var user oAuthUser + err = json.Unmarshal(data, &user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Decoding userinfo failed: %v", err)}) + return + } + // you would like it to contain. + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{ + jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Minute * 10).Unix(), + }, + "google", + user.Sub, + }) + + // Sign and get the complete encoded token as a string using the secret + tokenString, err := token.SignedString(h.config.Secret) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("could not sign token: %v", err)}) + return + } + c.HTML(http.StatusOK, "token.tmpl", gin.H{ + "token": tokenString, + }) +} diff --git a/handlers/handlers.go b/handlers/handlers.go index a84de46..059cbb1 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -3,18 +3,12 @@ package handlers import ( "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "github.com/gin-gonic/contrib/sessions" "github.com/gin-gonic/gin" "github.com/maxibanki/golang-url-shortener/config" "github.com/maxibanki/golang-url-shortener/store" + "github.com/pkg/errors" "golang.org/x/oauth2" - "golang.org/x/oauth2/google" ) // Handler holds the funcs and attributes for the @@ -26,35 +20,37 @@ type Handler struct { oAuthConf *oauth2.Config } -// URLUtil is used to help in- and outgoing requests for json -// un- and marshalling -type URLUtil struct { - URL string `binding:"required"` -} - -type oAuthUser struct { - Sub string `json:"sub"` - Name string `json:"name"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - Profile string `json:"profile"` - Picture string `json:"picture"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` - Gender string `json:"gender"` - Hd string `json:"hd"` -} - // New initializes the http handlers -func New(handlerConfig config.Handlers, store store.Store) *Handler { +func New(handlerConfig config.Handlers, store store.Store) (*Handler, error) { h := &Handler{ config: handlerConfig, store: store, engine: gin.Default(), } h.setHandlers() + err := h.checkIfSecretExist() + if err != nil { + return nil, errors.Wrap(err, "could not check if secret exist") + } h.initOAuth() - return h + return h, nil +} + +func (h *Handler) checkIfSecretExist() error { + conf := config.Get() + if conf.Handlers.Secret == nil { + b := make([]byte, 128) + _, err := rand.Read(b) + if err != nil { + return err + } + conf.Handlers.Secret = b + err = config.Set(conf) + if err != nil { + return err + } + } + return nil } func (h *Handler) setHandlers() { @@ -65,150 +61,7 @@ func (h *Handler) setHandlers() { h.engine.POST("/api/v1/info", h.handleInfo) // h.engine.Static("/static", "static/src") h.engine.NoRoute(h.handleAccess) -} - -func (h *Handler) initOAuth() { - store := sessions.NewCookieStore([]byte("secret")) - - h.oAuthConf = &oauth2.Config{ - ClientID: h.config.OAuth.Google.ClientID, - ClientSecret: h.config.OAuth.Google.ClientSecret, - RedirectURL: "http://127.0.0.1:3000/api/v1/auth/", - Scopes: []string{ - "https://www.googleapis.com/auth/userinfo.email", - }, - Endpoint: google.Endpoint, - } - h.engine.Use(sessions.Sessions("goquestsession", store)) - h.engine.GET("/api/v1/login", h.handleGoogleLogin) - - private := h.engine.Group("/api/v1/auth") - private.Use(h.handleGoogleAuth) - private.GET("/", h.handleGoogleCallback) - private.GET("/api", func(c *gin.Context) { - c.JSON(200, gin.H{"message": "Hello from private for groups"}) - }) -} - -func (h *Handler) randToken() string { - b := make([]byte, 32) - rand.Read(b) - return base64.StdEncoding.EncodeToString(b) -} - -func (h *Handler) handleGoogleAuth(c *gin.Context) { - session := sessions.Default(c) - retrievedState := session.Get("state") - if retrievedState != c.Query("state") { - c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Errorf("Invalid session state: %s", retrievedState)}) - return - } - - token, err := h.oAuthConf.Exchange(oauth2.NoContext, c.Query("code")) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - client := h.oAuthConf.Client(oauth2.NoContext, token) - userinfo, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - defer userinfo.Body.Close() - data, err := ioutil.ReadAll(userinfo.Body) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Could not read body: %v", err)}) - return - } - - var user oAuthUser - err = json.Unmarshal(data, &user) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Decoding userinfo failed: %v", err)}) - return - } - c.Set("user", user) -} - -func (h *Handler) handleGoogleLogin(c *gin.Context) { - state := h.randToken() - session := sessions.Default(c) - session.Set("state", state) - session.Save() - c.Redirect(http.StatusTemporaryRedirect, h.oAuthConf.AuthCodeURL(state)) -} - -func (h *Handler) handleGoogleCallback(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"Hello": "from private", "user": c.MustGet("user").(oAuthUser)}) -} - -// handleCreate handles requests to create an entry -func (h *Handler) handleCreate(c *gin.Context) { - var data URLUtil - err := c.ShouldBind(&data) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - id, err := h.store.CreateEntry(data.URL, c.ClientIP()) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - data.URL = h.getSchemaAndHost(c) + "/" + id - c.JSON(http.StatusOK, data) -} - -func (h *Handler) getSchemaAndHost(c *gin.Context) string { - protocol := "http" - if c.Request.TLS != nil { - protocol = "https" - } - return fmt.Sprintf("%s://%s", protocol, c.Request.Host) -} - -// handleInfo is the http handler for getting the infos -func (h *Handler) handleInfo(c *gin.Context) { - var data struct { - ID string `binding:"required"` - } - err := c.ShouldBind(&data) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - entry, err := h.store.GetEntryByID(data.ID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - entry.RemoteAddr = "" - c.JSON(http.StatusOK, entry) -} - -// handleAccess handles the access for incoming requests -func (h *Handler) handleAccess(c *gin.Context) { - var id string - if len(c.Request.URL.Path) > 1 { - id = c.Request.URL.Path[1:] - } - entry, err := h.store.GetEntryByID(id) - if err == store.ErrIDIsEmpty || err == store.ErrNoEntryFound { - return // return normal 404 error if such an error occurs - } else if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - err = h.store.IncreaseVisitCounter(id) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.Redirect(http.StatusTemporaryRedirect, entry.URL) + h.engine.LoadHTMLGlob("templates/*") } // Listen starts the http server diff --git a/handlers/public.go b/handlers/public.go new file mode 100644 index 0000000..f20e3a9 --- /dev/null +++ b/handlers/public.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/maxibanki/golang-url-shortener/store" +) + +// URLUtil is used to help in- and outgoing requests for json +// un- and marshalling +type URLUtil struct { + URL string `binding:"required"` +} + +// handleInfo is the http handler for getting the infos +func (h *Handler) handleInfo(c *gin.Context) { + var data struct { + ID string `binding:"required"` + } + err := c.ShouldBind(&data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + entry, err := h.store.GetEntryByID(data.ID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + entry.RemoteAddr = "" + c.JSON(http.StatusOK, entry) +} + +// handleAccess handles the access for incoming requests +func (h *Handler) handleAccess(c *gin.Context) { + var id string + if len(c.Request.URL.Path) > 1 { + id = c.Request.URL.Path[1:] + } + entry, err := h.store.GetEntryByID(id) + if err == store.ErrIDIsEmpty || err == store.ErrNoEntryFound { + return // return normal 404 error if such an error occurs + } else if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + err = h.store.IncreaseVisitCounter(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Redirect(http.StatusTemporaryRedirect, entry.URL) +} + +// handleCreate handles requests to create an entry +func (h *Handler) handleCreate(c *gin.Context) { + var data URLUtil + err := c.ShouldBind(&data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id, err := h.store.CreateEntry(data.URL, c.ClientIP()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + data.URL = h.getSchemaAndHost(c) + "/" + id + c.JSON(http.StatusOK, data) +} diff --git a/handlers/utils.go b/handlers/utils.go new file mode 100644 index 0000000..edfafd9 --- /dev/null +++ b/handlers/utils.go @@ -0,0 +1,23 @@ +package handlers + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + + "github.com/gin-gonic/gin" +) + +func (h *Handler) getSchemaAndHost(c *gin.Context) string { + protocol := "http" + if c.Request.TLS != nil { + protocol = "https" + } + return fmt.Sprintf("%s://%s", protocol, c.Request.Host) +} + +func (h *Handler) randToken() string { + b := make([]byte, 32) + rand.Read(b) + return base64.StdEncoding.EncodeToString(b) +} diff --git a/main.go b/main.go index 4f00790..86f91d0 100644 --- a/main.go +++ b/main.go @@ -24,15 +24,19 @@ func main() { } func initShortener() (func(), error) { - config, err := config.Get() + err := config.Preload() if err != nil { return nil, errors.Wrap(err, "could not get config") } - store, err := store.New(config.Store) + conf := config.Get() + store, err := store.New(conf.Store) if err != nil { return nil, errors.Wrap(err, "could not create store") } - handler := handlers.New(config.Handlers, *store) + handler, err := handlers.New(conf.Handlers, *store) + if err != nil { + return nil, errors.Wrap(err, "could not create handlers") + } go func() { err := handler.Listen() if err != nil { diff --git a/static/src/App/App.js b/static/src/App/App.js index 7095343..39eab21 100644 --- a/static/src/App/App.js +++ b/static/src/App/App.js @@ -25,13 +25,19 @@ class ContainerExampleContainer extends Component { this.setState({ open: true }) } + onAuthCallback = data => { + window.removeEventListener('onAuthCallback', this.onAuthCallback); + var token = data.detail.token; + } + onAuthClick = () => { console.log("onAuthClick") + window.addEventListener('onAuthCallback', this.onAuthCallback, false); var wwidth = 400, wHeight = 500; var wLeft = (window.screen.width / 2) - (wwidth / 2); var wTop = (window.screen.height / 2) - (wHeight / 2); - window.open("/api/v1/login", "", `width=${wwidth}, height=${wHeight}, top=${wTop}, left=${wLeft}`) + window.open("/api/v1/login", "", `width=${wwidth}, height=${wHeight}, top=${wTop}, left=${wLeft}, menubar=0, toolbar=0`) } render() { diff --git a/templates/token.tmpl b/templates/token.tmpl new file mode 100644 index 0000000..aba7558 --- /dev/null +++ b/templates/token.tmpl @@ -0,0 +1,23 @@ + + + + + + + + You will be redirected + + + + + + + + \ No newline at end of file