From c44ec98e39fc24f1291329706467ac00bde0e631 Mon Sep 17 00:00:00 2001 From: Piotr Biernat Date: Sun, 19 Jun 2022 15:19:05 +0200 Subject: [PATCH] [feature] Merged with api-prototype --- .env.dist | 2 + .gitignore | 2 + cmd/main.go | 45 ++++++++++- internal/app/config/env.go | 22 ++++++ internal/app/database/connect.go | 16 ++++ internal/app/definition/auth.go | 9 +++ internal/app/definition/error.go | 9 +++ internal/app/entity/user.go | 18 +++++ internal/app/handler/auth.go | 35 +++++++++ internal/app/handler/error.go | 15 ++++ internal/app/handler/handler.go | 83 ++++++++++++++++++++ internal/app/handler/health_check.go | 40 ++++++++++ internal/app/log.go | 16 ++++ internal/app/router.go | 26 ++++++ internal/app/server.go | 85 ++++++++++++++++++++ internal/app/service/auth.go | 113 +++++++++++++++++++++++++++ 16 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 .env.dist create mode 100644 internal/app/config/env.go create mode 100644 internal/app/database/connect.go create mode 100644 internal/app/definition/auth.go create mode 100644 internal/app/definition/error.go create mode 100644 internal/app/entity/user.go create mode 100644 internal/app/handler/auth.go create mode 100644 internal/app/handler/error.go create mode 100644 internal/app/handler/handler.go create mode 100644 internal/app/handler/health_check.go create mode 100644 internal/app/log.go create mode 100644 internal/app/router.go create mode 100644 internal/app/server.go create mode 100644 internal/app/service/auth.go diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..d6702a0 --- /dev/null +++ b/.env.dist @@ -0,0 +1,2 @@ +HTTP_PORT=8000 +DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/svc_identity diff --git a/.gitignore b/.gitignore index f4d432a..2f5f09c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +.env + # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/cmd/main.go b/cmd/main.go index 508c9e7..2305ed4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,7 +1,48 @@ package main -import "fmt" +import ( + "context" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/config" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/database" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/handler" + "net" + "os" + "os/signal" + "time" +) + +const ( + defHttpIp = "127.0.0.1" + defHttpPort = "8080" + defDbUrl = "postgres://postgres:postgres@127.0.0.1:5432/egommerce" // FIXME: use env +) func main() { - fmt.Println("Identity services") + if config.ErrLoadingEnvs != nil { + app.Panicf("Error loading .env file") + } + + httpAddr := net.JoinHostPort(config.GetEnv("SERVER_IP", defHttpIp), config.GetEnv("SERVER_PORT", defHttpPort)) + dbConnStr := config.GetEnv("DATABASE_URL", defDbUrl) + + dbc, err := database.Connect(dbConnStr) + if err != nil { + app.Panicf("Unable to connect to database: %v\n", err) + } + + env := &handler.Env{httpAddr, dbc} + srv := app.NewServer(env) + + go srv.Start() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + srv.Shutdown(ctx) + os.Exit(0) } diff --git a/internal/app/config/env.go b/internal/app/config/env.go new file mode 100644 index 0000000..f08d8ff --- /dev/null +++ b/internal/app/config/env.go @@ -0,0 +1,22 @@ +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +var ErrLoadingEnvs error + +func init() { + ErrLoadingEnvs = godotenv.Load() +} + +func GetEnv(name, defVal string) string { + env := os.Getenv(name) + if env == "" { + return defVal + } + + return env +} diff --git a/internal/app/database/connect.go b/internal/app/database/connect.go new file mode 100644 index 0000000..b44cfdd --- /dev/null +++ b/internal/app/database/connect.go @@ -0,0 +1,16 @@ +package database + +import ( + "context" + + "github.com/jackc/pgx/v4/pgxpool" +) + +func Connect(connStr string) (*pgxpool.Pool, error) { + conn, err := pgxpool.Connect(context.Background(), connStr) + if err != nil { + return nil, err + } + + return conn, nil +} diff --git a/internal/app/definition/auth.go b/internal/app/definition/auth.go new file mode 100644 index 0000000..f32ca44 --- /dev/null +++ b/internal/app/definition/auth.go @@ -0,0 +1,9 @@ +package definition + +type AuthLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthLoginResponse struct { +} diff --git a/internal/app/definition/error.go b/internal/app/definition/error.go new file mode 100644 index 0000000..28f7dbf --- /dev/null +++ b/internal/app/definition/error.go @@ -0,0 +1,9 @@ +package definition + +type ErrorResponse struct { + Error string `json:"error"` +} + +func Error(err string) *ErrorResponse { + return &ErrorResponse{err} +} diff --git a/internal/app/entity/user.go b/internal/app/entity/user.go new file mode 100644 index 0000000..2b0b578 --- /dev/null +++ b/internal/app/entity/user.go @@ -0,0 +1,18 @@ +package entity + +import "time" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + CreateDate time.Time `json:"create_date"` + ModifyDate time.Time `json:"modify_date"` // FIXME: zero-value issue +} + +var TestUser = &User{ + ID: 1, + Username: "test", + Password: "test", + CreateDate: time.Now(), +} diff --git a/internal/app/handler/auth.go b/internal/app/handler/auth.go new file mode 100644 index 0000000..65a1eb2 --- /dev/null +++ b/internal/app/handler/auth.go @@ -0,0 +1,35 @@ +package handler + +import ( + "net/http" + + def "git.pbiernat.dev/egommerce/application/services/identity/internal/app/definition" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/service" +) + +var AuthLoginHandler *Handler + +func init() { + AuthLoginHandler = &Handler{ + Handle: AuthLoginHandlerFunc, + Request: &def.AuthLoginRequest{}, + Response: &def.AuthLoginResponse{}, + } +} + +func AuthLoginHandlerFunc(h *Handler, w http.ResponseWriter) (interface{}, int, error) { + var req = h.Request.(*def.AuthLoginRequest) + // u := entity.TestUser + + token, err := service.AuthService.Login(req) + if err != nil { + return nil, http.StatusUnauthorized, err + } + + service.AuthService.SetCookie(w, service.AuthService.TokenCookieName, token) + // service.AuthService.SetCookie(w, service.AuthService.RefreshTokenCookieName, refreshTtoken) + + // log.Println("user:", u, "req:", token, "err:", err) + + return nil, http.StatusOK, nil +} diff --git a/internal/app/handler/error.go b/internal/app/handler/error.go new file mode 100644 index 0000000..540b989 --- /dev/null +++ b/internal/app/handler/error.go @@ -0,0 +1,15 @@ +package handler + +import "net/http" + +type NotFoundHandler struct{} + +func (NotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + encodeResponse(w, &response{http.StatusNotFound, "Path " + r.RequestURI + " not found"}, nil) +} + +type MethodNotAllowedHandler struct{} + +func (MethodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + encodeResponse(w, &response{http.StatusMethodNotAllowed, "Method Not Allowed: " + r.Method}, nil) +} diff --git a/internal/app/handler/handler.go b/internal/app/handler/handler.go new file mode 100644 index 0000000..87a0192 --- /dev/null +++ b/internal/app/handler/handler.go @@ -0,0 +1,83 @@ +package handler + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "log" + "net/http" + + def "git.pbiernat.dev/egommerce/application/services/identity/internal/app/definition" + "github.com/gorilla/mux" + "github.com/jackc/pgx/v4/pgxpool" +) + +type Env struct { + Addr string + DB *pgxpool.Pool +} + +type Handler struct { + *Env + Handle HandlerFunc + Request interface{} + Response interface{} + Params Set +} + +type HandlerFunc func(h *Handler, w http.ResponseWriter) (interface{}, int, error) + +type Set map[string]string + +type response struct { + Status int + Data interface{} +} + +func Init(e *Env, h *Handler) *Handler { + // return &Handler{e, h.Handle, h.Request, h.Response, Set{}} + h.Env = e + + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := decodeRequestData(r, h.Request); err != nil { + log.Println("Decode request data error:", err.Error()) + + w.WriteHeader(http.StatusInternalServerError) + } + + h.Params = mux.Vars(r) + res, code, err := h.Handle(h, w) + + encodeResponse(w, &response{code, res}, err) +} + +func decodeRequestData(r *http.Request, v interface{}) error { + buf, _ := ioutil.ReadAll(r.Body) + rdr := ioutil.NopCloser(bytes.NewReader(buf)) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + + json.NewDecoder(rdr).Decode(&v) + + return nil +} + +func encodeResponse(w http.ResponseWriter, res *response, err error) { + if err != nil { + encodeError(w, res.Status, err) + return + } + + w.WriteHeader(res.Status) + if res.Data != nil { + json.NewEncoder(w).Encode(res.Data) + } +} + +func encodeError(w http.ResponseWriter, status int, e error) { + w.WriteHeader(status) + + json.NewEncoder(w).Encode(def.Error(e.Error())) +} diff --git a/internal/app/handler/health_check.go b/internal/app/handler/health_check.go new file mode 100644 index 0000000..6681dc9 --- /dev/null +++ b/internal/app/handler/health_check.go @@ -0,0 +1,40 @@ +package handler + +import ( + "net/http" +) + +var HealthCheckHandler *Handler + +func init() { + HealthCheckHandler = &Handler{ + Handle: HealthCheckHandlerFunc, + Request: &HealthCheckRequest{}, + Response: &HealthCheckResponse{}, + } +} + +type HealthCheckRequest struct { +} + +type HealthCheckResponse struct { + Status string `json:"status"` + Data *HealthCheckResponseBody `json:"data"` +} + +type HealthCheckResponseBody struct { + Message string `json:"message,omitempty"` +} + +func HealthCheckHandlerFunc(_ *Handler, w http.ResponseWriter) (interface{}, int, error) { + return &HealthCheckResponseBody{ + Message: "This is welcome health message. Everything seems to be alright ;)", + }, http.StatusOK, nil + + // return &HealthCheckResponse{ + // Status: http.StatusText(http.StatusOK), + // Data: &HealthCheckResponseBody{ + // Message: "This is welcome health message. Everything seems to be alright ;)", + // }, + // }, http.StatusOK, nil +} diff --git a/internal/app/log.go b/internal/app/log.go new file mode 100644 index 0000000..53fe9c1 --- /dev/null +++ b/internal/app/log.go @@ -0,0 +1,16 @@ +package app + +import "log" + +func Panic(v ...any) { + log.Panicln(Name + ":", v) +} + +func Panicf(format string, v ...any) { + log.Panicf(Name + ": " + format, v...) +} + +func Panicln(v ...any) { + v = append([]any{Name + ":"}, v...) + log.Panicln(v...) +} \ No newline at end of file diff --git a/internal/app/router.go b/internal/app/router.go new file mode 100644 index 0000000..f5d731c --- /dev/null +++ b/internal/app/router.go @@ -0,0 +1,26 @@ +package app + +import ( + "net/http" + + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/handler" + "github.com/gorilla/mux" +) + +func SetupRouter(env *handler.Env) *mux.Router { + r := mux.NewRouter() + r.NotFoundHandler = &handler.NotFoundHandler{} + r.MethodNotAllowedHandler = &handler.MethodNotAllowedHandler{} + + r.Use(PrepareHeadersMiddleware) + r.Use(ValidateJsonBodyMiddleware) // probably not needed + r.Use(LoggingMiddleware) + + hc := r.PathPrefix("/health").Subrouter() + hc.Handle("", handler.Init(env, handler.HealthCheckHandler)).Methods(http.MethodGet) + + auth := r.PathPrefix("/auth").Subrouter() + auth.Handle("/login", handler.Init(env, handler.AuthLoginHandler)).Methods(http.MethodPost) + + return r +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..f0d955c --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,85 @@ +package app + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "strconv" + "time" + + def "git.pbiernat.dev/egommerce/application/services/identity/internal/app/definition" + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/handler" +) + +const Name = "REST API Service" + +type Server struct { + *http.Server +} + +func NewServer(env *handler.Env) *Server { + return &Server{ + &http.Server{ + Handler: SetupRouter(env), + Addr: env.Addr, + WriteTimeout: 15 * time.Second, + ReadTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + }, + } +} + +func (s *Server) Start() { + if os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()) { + // systemd run + f := os.NewFile(3, "from systemd") + l, err := net.FileListener(f) + if err != nil { + log.Fatalln(err) + } + + log.Println("Server listening on " + l.Addr().String()) + s.Serve(l) + } else { + + log.Println("Server listening on " + s.Addr) + log.Fatalln(s.ListenAndServe()) + } +} + +func PrepareHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Keep-Alive", "timeout=5") + + next.ServeHTTP(w, r) + }) +} + +func ValidateJsonBodyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf, _ := ioutil.ReadAll(r.Body) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) // rollack *Request to original state + + if len(buf) > 0 && !json.Valid(buf) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(def.Error("Unable to parse JSON: " + string(buf))) + + return + } + + next.ServeHTTP(w, r) + }) +} + +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Println("Request: " + r.RequestURI + " remote: " + r.RemoteAddr + " via: " + r.UserAgent()) + next.ServeHTTP(w, r) + }) +} diff --git a/internal/app/service/auth.go b/internal/app/service/auth.go new file mode 100644 index 0000000..1c62e64 --- /dev/null +++ b/internal/app/service/auth.go @@ -0,0 +1,113 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "git.pbiernat.dev/egommerce/application/services/identity/internal/app/config" + def "git.pbiernat.dev/egommerce/application/services/identity/internal/app/definition" + "github.com/golang-jwt/jwt" +) + +var ( + AuthService *Auth + + ErrUserNotFound = errors.New("user not found") + ErrTokenError = errors.New("failed to generate JWT token") +) + +func init() { + expire, _ := strconv.Atoi(config.GetEnv("AUTH_TOKEN_EXPIRE_TIME", "5")) + secret := []byte(config.GetEnv("AUTH_SECRET_HMAC", "B413IlIv9nKQfsMCXTE0Cteo4yHgUEfqaLfjg73sNlh")) + + AuthService = &Auth{expire, "jwt_token", "jwt_token_refresh", secret} +} + +type Auth struct { + ExpireTime int // token expire time in minutes + TokenCookieName string + RefreshTokenCookieName string + + secret []byte // signing key +} + +func (a *Auth) Login(r *def.AuthLoginRequest) (string, error) { + if r.Username == "admin" && r.Password == "secret" { + token, err := a.createToken() + if err != nil { + return "", ErrTokenError + } + + return token, nil + } + + return "", ErrUserNotFound +} + +// SetCookie appends cookie header to response +func (a *Auth) SetCookie(w http.ResponseWriter, name, token string) { + c := &http.Cookie{ + Name: name, + Value: token, + MaxAge: a.ExpireTime * 60, + Path: "/", + } + http.SetCookie(w, c) +} + +func (a Auth) createToken() (string, error) { + // log.Println("now:", time.Now().Unix()) + // log.Println("expire at:", time.Now().Add(time.Duration(a.ExpireTime)*time.Minute).Unix()) + claims := &jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Duration(a.ExpireTime) * time.Minute).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(a.secret) +} + +func (a *Auth) validateToken(tokenStr string) error { + token, err := jwt.Parse(tokenStr, 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"]) + } + + // hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key") + return a.secret, nil + }) + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + log.Println(claims) + } else { + return err + } + + return nil +} + +func (a Auth) ValidateUserTokenMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cToken, err := r.Cookie(a.TokenCookieName) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(def.Error("Missing JWT Token cookie")) + + return + } + + if err := a.validateToken(cToken.Value); err != nil { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(def.Error(err.Error())) + + return + } + + next.ServeHTTP(w, r) + }) +}