diff --git a/go.mod b/go.mod index 5abd583..5e2fc28 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( require ( github.com/andybalholm/brotli v1.0.4 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/klauspost/compress v1.14.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.33.0 // indirect diff --git a/src/api/api.go b/src/api/api.go index c6ac879..52cea0f 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -12,7 +12,7 @@ import ( ) // Method must be a valid REST method, like GET, POST, PUT, DELETE etc -func (api Api) Call(method string, path string, jsonPayload []byte) (ApiRes, error) { +func (api Api) Call(method string, path string, payload []byte) (ApiRes, error) { respObj := ApiRes{} adminToken, _, err := api.getAdminToken() @@ -20,7 +20,7 @@ func (api Api) Call(method string, path string, jsonPayload []byte) (ApiRes, err return ApiRes{}, err } - req, err := http.NewRequest(method, api.URL+path, bytes.NewBuffer(jsonPayload)) + req, err := http.NewRequest(method, api.URL+path, bytes.NewBuffer(payload)) if err != nil { api.Log.Error("Could not create request", "method", method, "err", err.Error()) return ApiRes{}, err @@ -36,18 +36,12 @@ func (api Api) Call(method string, path string, jsonPayload []byte) (ApiRes, err } defer res.Body.Close() - api.Log.Debug("API res status", "status", res.StatusCode) + api.Log.Debug(api.URL+path+" res status", "status", res.StatusCode) respObj.StatusCode = res.StatusCode if strconv.Itoa(res.StatusCode)[0:1] == "5" { api.Log.Error("API gave internal server error", "statusCode", res.StatusCode) return ApiRes{}, errors.New("API gave internal server error") - } else if res.StatusCode == 403 { - api.Log.Error("API responded with 403, Forbidden") - return ApiRes{}, errors.New("API responded with 403, Forbidden") - } else if res.StatusCode == 401 { - api.Log.Error("API responded with 401, Unauthorized") - return ApiRes{}, errors.New("API responded with 403, Unauthorized") } body, err := ioutil.ReadAll(res.Body) @@ -107,7 +101,7 @@ func (api Api) getAdminTokenCall() (string, error) { } defer res.Body.Close() - api.Log.Debug("API res status", "status", res.StatusCode) + api.Log.Debug(api.URL+"/auth/api-key res status", "status", res.StatusCode) if strconv.Itoa(res.StatusCode)[0:1] == "5" { api.Log.Error("API gave internal server error", "statusCode", res.StatusCode) diff --git a/src/handlers/index.go b/src/handlers/index.go index 7286d81..3c59d3c 100644 --- a/src/handlers/index.go +++ b/src/handlers/index.go @@ -1,12 +1,88 @@ package handlers import ( + "encoding/json" + "strconv" + "github.com/gofiber/fiber/v2" + "gitlab.larvit.se/power-plan/auth-ui/src/api" + "gitlab.larvit.se/power-plan/auth-ui/src/utils" "gitlab.larvit.se/power-plan/auth-ui/src/views" ) +type LoginUser struct { + Username string `json:"name"` + Password string `json:"password"` +} + +// Index GET func (h Handlers) Index(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - return c.SendString(views.Index()) + return c.SendString(views.Index(views.IndexData{})) +} + +// Index POST +func (h Handlers) IndexPost(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) + loginUser := new(LoginUser) + + err := c.BodyParser(loginUser) + if err != nil { + h.Log.Debug("Invalid input data", "err", err.Error()) + c.Status(400) + } + + jsonPayload, err := json.Marshal(loginUser) + if err != nil { + h.Log.Error("Could not marshal loginUser struct", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + apiRes, err := h.Api.Call("POST", "/auth/password", jsonPayload) + if err != nil { + h.Log.Error("Could not call backend API", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + h.Log.Debug("API res status", "status", apiRes.StatusCode) + + if apiRes.StatusCode == 200 { + err = utils.SetTokenCookies(apiRes.Body, c, h.Log) + if err != nil { + h.Log.Error("Could not unmarshal body from auth API", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + userToken := c.Cookies("userToken") + parsed, err := utils.ParseAndValidateToken(userToken, h.Api.JwtSharedSecret, h.Log) + if err != nil { + h.Log.Error("Could not parse and validate new user token after renewal", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + c.Locals("user", parsed) + + return c.SendString(views.Index(views.IndexData{ + OkMsg: "User logged in!", + Title: "Dashboard", + })) + } else if strconv.Itoa(apiRes.StatusCode)[0:1] == "4" { + var jsonResp []api.ApiResError + + err := json.Unmarshal(apiRes.Body, &jsonResp) + if err != nil { + h.Log.Error("Could not unmarshal api error response", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + return c.Status(apiRes.StatusCode).SendString(views.Index(views.IndexData{ + ErrField: jsonResp[0].Field, + ErrMsg: jsonResp[0].Error, + Title: "Login", + })) + } else { + h.Log.Error("Unexpected API response status", "status", apiRes.StatusCode) + return c.Status(500).SendString(views.Error500()) + } } diff --git a/src/handlers/logout.go b/src/handlers/logout.go new file mode 100644 index 0000000..2126360 --- /dev/null +++ b/src/handlers/logout.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" +) + +// Logout +func (h Handlers) LogoutPost(c *fiber.Ctx) error { + c.ClearCookie("userToken") + c.ClearCookie("renewalToken") + c.Locals("user", "") + + c.Redirect("/") + return nil +} diff --git a/src/handlers/middlewares.go b/src/handlers/middlewares.go index baa9b07..57566b8 100644 --- a/src/handlers/middlewares.go +++ b/src/handlers/middlewares.go @@ -1,13 +1,76 @@ package handlers import ( + "errors" + "strings" + "github.com/gofiber/fiber/v2" + "gitlab.larvit.se/power-plan/auth-ui/src/utils" + "gitlab.larvit.se/power-plan/auth-ui/src/views" ) -// Log all requests +// Log all requests. func (h Handlers) LogReq(c *fiber.Ctx) error { h.Log.Debug("http request", "method", c.Method(), "url", c.OriginalURL()) c.Next() return nil } + +// Token cookie handling. +func (h Handlers) HandleCookieTokens(c *fiber.Ctx) error { + userToken := c.Cookies("userToken") + + // If no userToken exists, nothing more to do, so exiting. + if len(userToken) == 0 { + return c.Next() + } + + parsed, err := utils.ParseAndValidateToken(userToken, h.Api.JwtSharedSecret, h.Log) + // If parsing went fine, just set the user context local and exit. + if err == nil { + c.Locals("user", parsed) + return c.Next() + } + + // Something is wrong and it is not that the token has expired. + // Log the error and exit. + if !strings.HasPrefix(err.Error(), "token is expired") { + h.Log.Error("could not parse token with claims", "err", err.Error()) + return c.Next() + } + + renewalToken := c.Cookies("renewalToken") + + if len(renewalToken) == 0 { + h.Log.Debug("no renewalToken cookie set, can not renew expired token") + return errors.New("no renewalToken cookie set, can not renew expired token") + } + + apiRes, err := h.Api.Call("POST", "/renew-token", []byte("\""+renewalToken+"\"")) + if err != nil { + h.Log.Error("Could not call backend API", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + if apiRes.StatusCode != 200 { + h.Log.Error("Unexpected API response status", "status", apiRes.StatusCode) + return c.Status(500).SendString(views.Error500()) + } + + err = utils.SetTokenCookies(apiRes.Body, c, h.Log) + if err != nil { + h.Log.Error("Could not unmarshal body from auth API", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + userToken = c.Cookies("userToken") + parsed, err = utils.ParseAndValidateToken(userToken, h.Api.JwtSharedSecret, h.Log) + if err != nil { + h.Log.Error("Could not parse and validate new user token after renewal", "err", err.Error()) + return c.Status(500).SendString(views.Error500()) + } + + c.Locals("user", parsed) + return c.Next() +} diff --git a/src/handlers/register.go b/src/handlers/register.go index ec06a4e..36d1206 100644 --- a/src/handlers/register.go +++ b/src/handlers/register.go @@ -15,16 +15,21 @@ func (h Handlers) Register(c *fiber.Ctx) error { return c.SendString(views.Register(views.RegisterData{})) } -type NewUser struct { - Name string `json:"name"` - Username string `json:"username"` - Password string `json:"password"` - RepeatPassword string `json:"-"` +type field struct { + Name string `json:"name"` + Values []string `json:"values"` +} +type newUserType struct { + Name string `json:"name"` + Username string `json:"username"` + Password string `json:"password"` + RepeatPassword string `json:"-"` + Fields []field `json:"fields"` } func (h Handlers) RegisterPost(c *fiber.Ctx) error { c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) - newUser := new(NewUser) + newUser := new(newUserType) err := c.BodyParser(newUser) if err != nil { @@ -32,6 +37,10 @@ func (h Handlers) RegisterPost(c *fiber.Ctx) error { c.Status(400) } newUser.Name = newUser.Username + newUser.Fields = append(newUser.Fields, field{ + Name: "role", + Values: []string{"user"}, + }) jsonPayload, err := json.Marshal(newUser) if err != nil { diff --git a/src/main.go b/src/main.go index cf67a06..ae3338c 100644 --- a/src/main.go +++ b/src/main.go @@ -49,11 +49,17 @@ func main() { // Static files app.Static("/", "./src/public") + // Unpack user token, refresh token when needed etc. + app.Use(handlers.HandleCookieTokens) + app.Get("/", handlers.Index) + app.Post("/", handlers.IndexPost) app.Get("/register", handlers.Register) app.Post("/register", handlers.RegisterPost) + app.Post("/logout", handlers.LogoutPost) + log.Info("Trying to start web server", "PORT", os.Getenv("PORT")) webBindHost := ":" + os.Getenv("PORT") diff --git a/src/utils/utils.go b/src/utils/utils.go index dfd21ae..49b3ea7 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -1,9 +1,14 @@ package utils import ( + "encoding/json" + "fmt" "log" + "strings" "time" + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -35,3 +40,65 @@ func GetLog() *zap.SugaredLogger { return log } + +type authClaims struct { + jwt.StandardClaims + AccountId string `json:"accountId"` + AccountFields map[string][]string `json:"accountFields"` + AccountName string `json:"accountName"` +} + +func ParseAndValidateToken(token string, jwtSecret string, log *zap.SugaredLogger) (authClaims, error) { + var claims authClaims + + _, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { + _, ok := token.Method.(*jwt.SigningMethodHMAC) + if !ok { + log.Error("unexpected signing method", "signingMethod", token.Header["alg"]) + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(jwtSecret), nil + }) + if err != nil { + if strings.HasPrefix(err.Error(), "token is expired") { + log.Debug("token have expired", "err", err.Error()) + } else { + log.Error("could not parse token with claims", "err", err.Error()) + } + return authClaims{}, err + } + + return claims, nil +} + +type AuthRes struct { + Jwt string `json:"jwt"` + RenewalToken string `json:"renewalToken"` +} + +func SetTokenCookies(apiResBody []byte, c *fiber.Ctx, log *zap.SugaredLogger) error { + var authRes AuthRes + err := json.Unmarshal(apiResBody, &authRes) + if err != nil { + log.Error("Could not unmarshal body from auth API", "err", err.Error()) + return err + } + + c.Cookie(&fiber.Cookie{ + Name: "userToken", + Value: authRes.Jwt, + Expires: time.Now().Add(24 * time.Hour), + HTTPOnly: false, + SameSite: "lax", + }) + + c.Cookie(&fiber.Cookie{ + Name: "renewalToken", + Value: authRes.RenewalToken, + Expires: time.Now().Add(24 * time.Hour), + HTTPOnly: true, + SameSite: "lax", + }) + + return nil +} diff --git a/src/views/index.go b/src/views/index.go index 83f15ba..aa79d22 100644 --- a/src/views/index.go +++ b/src/views/index.go @@ -1,31 +1,62 @@ package views -import "gitlab.larvit.se/power-plan/auth-ui/src/views/layouts" +import ( + vu "gitlab.larvit.se/power-plan/auth-ui/src/view-utils" + "gitlab.larvit.se/power-plan/auth-ui/src/views/layouts" +) -func Index() string { - content := ` -

Login

-
-
-
- - -
+type UserData struct { + Name string +} -
- - -
+type IndexData struct { + ErrField string + ErrMsg string + OkMsg string + Title string + UserData UserData + UserLoggedIn bool +} -
- Register - -
-
-
` +func Index(data IndexData) string { + content := "" + + if data.UserLoggedIn { + content = ` +

Welcome ` + data.UserData.Name + `

` + } else { + content = ` +

Login

+
+ ` + vu.TernStr(len(data.OkMsg) != 0, "

"+data.OkMsg+" Login

", "") + ` +
+
+ + +
+ +
+ + +
+ + ` + vu.TernStr(len(data.ErrMsg) != 0, "

ERROR! "+data.ErrMsg+"

", "") + ` + +

+ Register + +
+
+
+ +
+ +
+ ` + } return layouts.Default(layouts.DefaultData{ Content: content, - Title: "Login", + Title: data.Title, }) }