From 7a9e5112eaaea58d55f181d3e5296e4ff839921c Mon Sep 17 00:00:00 2001 From: jloup Date: Wed, 14 Feb 2018 14:19:09 +0100 Subject: initial commit --- api/api.go | 130 +++++++++++++++++++++++++++++++++++++++++++++++++ api/auth_jwt.go | 107 +++++++++++++++++++++++++++++++++++++++++ api/auth_otp.go | 122 ++++++++++++++++++++++++++++++++++++++++++++++ api/const.go | 51 ++++++++++++++++++++ api/const_string.go | 28 +++++++++++ api/logger.go | 27 +++++++++++ api/market_config.go | 77 +++++++++++++++++++++++++++++ api/routes.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++ api/user.go | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 798 insertions(+) create mode 100644 api/api.go create mode 100644 api/auth_jwt.go create mode 100644 api/auth_otp.go create mode 100644 api/const.go create mode 100644 api/const_string.go create mode 100644 api/logger.go create mode 100644 api/market_config.go create mode 100644 api/routes.go create mode 100644 api/user.go (limited to 'api') diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..7b7be49 --- /dev/null +++ b/api/api.go @@ -0,0 +1,130 @@ +package api + +import ( + "net/http" + "unicode" + + "github.com/gin-gonic/gin" +) + +type Error struct { + Code ErrorCode + UserMessage string + err error +} + +func (e Error) Err() error { + if e.err != nil { + return e + } + + return nil +} + +func (e Error) Error() string { + if e.err != nil { + return e.err.Error() + } + + return "" +} + +func NewInternalError(err error) *Error { + return &Error{InternalError, "internal error", err} +} + +func ToSnake(in string) string { + runes := []rune(in) + length := len(runes) + + var out []rune + for i := 0; i < length; i++ { + if i > 0 && unicode.IsUpper(runes[i]) && ((i+1 < length && unicode.IsLower(runes[i+1])) || unicode.IsLower(runes[i-1])) { + out = append(out, '_') + } + out = append(out, unicode.ToLower(runes[i])) + } + + return string(out) +} + +type Response struct { + StatusCode Status `json:"-"` + ErrorCode ErrorCode `json:"-"` + + Status string `json:"status"` + Code string `json:"code,omitempty"` + Response interface{} `json:"response,omitempty"` + Message string `json:"message,omitempty"` +} + +func (r Response) populateStatus() Response { + r.Status = ToSnake(r.StatusCode.String()) + + if r.ErrorCode != 0 { + r.Code = ToSnake(r.ErrorCode.String()) + } + + return r +} + +func ErrorResponse(code ErrorCode, message string) Response { + return Response{ + StatusCode: NOK, + ErrorCode: code, + Message: message, + } +} + +func SuccessResponse(data interface{}) Response { + return Response{ + StatusCode: OK, + Response: data, + } +} + +func WriteJsonResponse(response Response, c *gin.Context) { + response = response.populateStatus() + + c.JSON(StatusToHttpCode(response.StatusCode, response.ErrorCode), response) +} + +func WriteBinary(contentType string, b []byte, c *gin.Context) { + c.Data(http.StatusOK, contentType, b) +} + +type Middleware func(*gin.Context) *Error + +func M(handler Middleware) gin.HandlerFunc { + return func(c *gin.Context) { + err := handler(c) + + if err != nil { + WriteJsonResponse(ErrorResponse(err.Code, err.UserMessage), c) + c.Error(err) + c.Abort() + } else { + c.Next() + } + } +} + +type Query interface { + ValidateParams() *Error + Run() (interface{}, *Error) +} + +func RunQuery(query Query, c *gin.Context) { + if err := query.ValidateParams(); err != nil { + WriteJsonResponse(ErrorResponse(err.Code, err.UserMessage), c) + c.Error(err) + return + } + + if out, err := query.Run(); err != nil { + WriteJsonResponse(ErrorResponse(err.Code, err.UserMessage), c) + c.Error(err) + } else { + WriteJsonResponse(SuccessResponse(out), c) + } +} diff --git a/api/auth_jwt.go b/api/auth_jwt.go new file mode 100644 index 0000000..f713f4e --- /dev/null +++ b/api/auth_jwt.go @@ -0,0 +1,107 @@ +package api + +import ( + "fmt" + "strings" + "time" + + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" + + "github.com/dgrijalva/jwt-go" + "github.com/gin-gonic/gin" +) + +// Static secret. +var JWT_SECRET []byte + +type JwtClaims struct { + Authorized bool `json:"authorized"` + Subject int64 `json:"sub,omitempty"` + jwt.StandardClaims +} + +func SetJwtSecretKey(secret string) { + JWT_SECRET = []byte(secret) +} + +func VerifyJwtToken(token string) (JwtClaims, error) { + if len(JWT_SECRET) == 0 { + return JwtClaims{}, fmt.Errorf("not initialized jwt secret") + } + + t, err := jwt.ParseWithClaims(token, &JwtClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"]) + } + + return JWT_SECRET, nil + }) + + claims, ok := t.Claims.(*JwtClaims) + + if !ok || !t.Valid || err != nil { + return JwtClaims{}, fmt.Errorf("invalid token (err: %v, claimsok: %v)", err, ok) + } + + return *claims, nil +} + +func SignJwt(claims JwtClaims) (string, error) { + if len(JWT_SECRET) == 0 { + return "", fmt.Errorf("not initialized jwt secret") + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, &claims) + + return token.SignedString(JWT_SECRET) +} + +func CreateJwtToken(userId int64) (string, error) { + claims := JwtClaims{ + false, + userId, + jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Hour * 24).Unix(), + }, + } + + return SignJwt(claims) +} + +func GetBearerToken(header string) (string, error) { + const prefix = "Bearer " + + if !strings.HasPrefix(header, prefix) { + return "", fmt.Errorf("invalid authorization token") + } + + return header[len(prefix):], nil +} + +func JwtAuth(c *gin.Context) *Error { + token, err := GetBearerToken(c.GetHeader("Authorization")) + if err != nil { + return &Error{NotAuthorized, "not authorized", err} + } + + claims, err := VerifyJwtToken(token) + if err != nil { + return &Error{NotAuthorized, "not authorized", err} + } + + user, err := db.GetUserById(claims.Subject) + if err != nil { + return &Error{NotAuthorized, "not authorized", err} + } + + c.Set("user", *user) + c.Set("claims", claims) + + return nil +} + +func GetClaims(c *gin.Context) JwtClaims { + claims, _ := c.Get("claims") + + return claims.(JwtClaims) +} diff --git a/api/auth_otp.go b/api/auth_otp.go new file mode 100644 index 0000000..de1cf24 --- /dev/null +++ b/api/auth_otp.go @@ -0,0 +1,122 @@ +package api + +import ( + "bytes" + "fmt" + "image/png" + + "github.com/gin-gonic/gin" + "github.com/pquerna/otp/totp" + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" +) + +func GenerateSecret(user db.User) (bytes.Buffer, string, error) { + var buf bytes.Buffer + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "cryptoportfolio", + AccountName: user.Email, + Period: 30, + }) + + if err != nil { + return buf, "", err + } + + qrImage, err := key.Image(200, 200) + if err != nil { + return buf, "", err + } + + png.Encode(&buf, qrImage) + if err != nil { + return buf, "", err + } + + return buf, key.Secret(), nil +} + +type OtpEnrollmentQuery struct { + In struct { + User db.User + } +} + +func (q OtpEnrollmentQuery) ValidateParams() *Error { + return nil +} + +func (q OtpEnrollmentQuery) Run() (*bytes.Buffer, string, *Error) { + if q.In.User.OtpSecret != "" && q.In.User.IsOtpSetup == true { + return nil, "", &Error{OtpAlreadySetup, "otp is already setup", fmt.Errorf("otp already setup")} + } + + buf, secret, err := GenerateSecret(q.In.User) + if err != nil { + return nil, "", NewInternalError(err) + } + + err = db.SetOtpSecret(&q.In.User, secret, true) + if err != nil { + return nil, "", NewInternalError(err) + } + + return &buf, secret, nil +} + +type OtpValidateQuery struct { + In struct { + Pass string + User db.User + Claims JwtClaims + } + + Out struct { + Token string `json:"token"` + } +} + +func (q OtpValidateQuery) ValidateParams() *Error { + if q.In.Pass == "" { + return &Error{InvalidOtp, "invalid otp", fmt.Errorf("invalid otp '%v'", q.In.Pass)} + } + + return nil +} + +func (q OtpValidateQuery) Run() (interface{}, *Error) { + var err error + + if q.In.User.OtpSecret == "" { + return nil, &Error{OtpNotSetup, "otp is not setup", fmt.Errorf("otp is not setup")} + } + + if !totp.Validate(q.In.Pass, q.In.User.OtpSecret) { + return nil, &Error{InvalidOtp, "invalid otp", fmt.Errorf("invalid otp '%v'", q.In.Pass)} + + } else if err := db.SetOtpSecret(&q.In.User, q.In.User.OtpSecret, false); err != nil { + return nil, NewInternalError(err) + } + + q.In.Claims.Authorized = true + q.Out.Token, err = SignJwt(q.In.Claims) + if err != nil { + return nil, NewInternalError(err) + } + + return q.Out, nil +} + +func OtpAuth(c *gin.Context) *Error { + claims := GetClaims(c) + user := GetUser(c) + + if user.IsOtpSetup == false || user.OtpSecret == "" { + return &Error{OtpNotSetup, "otp is not setup", fmt.Errorf("otp is not setup")} + } + + if !claims.Authorized { + return &Error{NeedOtpValidation, "not authorized", fmt.Errorf("otp not authorized")} + } + + return nil +} diff --git a/api/const.go b/api/const.go new file mode 100644 index 0000000..2edd6f4 --- /dev/null +++ b/api/const.go @@ -0,0 +1,51 @@ +package api + +import "net/http" + +//go:generate stringer -type=Status,ErrorCode -output const_string.go +type Status uint32 +type ErrorCode uint32 + +const ( + OK Status = iota + NOK + + BadRequest ErrorCode = iota + 1 + EmailExists + InternalError + InvalidCredentials + InvalidEmail + InvalidOtp + InvalidPassword + NeedOtpValidation + NotAuthorized + NotFound + OtpAlreadySetup + OtpNotSetup + UserNotConfirmed +) + +func StatusToHttpCode(status Status, code ErrorCode) int { + if status == OK { + return http.StatusOK + } + + switch code { + case BadRequest, InvalidPassword, InvalidEmail: + return http.StatusBadRequest + + case InvalidCredentials, InvalidOtp: + return http.StatusUnauthorized + + case UserNotConfirmed, NotAuthorized, OtpAlreadySetup, OtpNotSetup, NeedOtpValidation: + return http.StatusForbidden + + case EmailExists: + return http.StatusConflict + + case NotFound: + return http.StatusNotFound + } + + return http.StatusInternalServerError +} diff --git a/api/const_string.go b/api/const_string.go new file mode 100644 index 0000000..611db40 --- /dev/null +++ b/api/const_string.go @@ -0,0 +1,28 @@ +// Code generated by "stringer -type=Status,ErrorCode -output const_string.go"; DO NOT EDIT + +package api + +import "fmt" + +const _Status_name = "OKNOK" + +var _Status_index = [...]uint8{0, 2, 5} + +func (i Status) String() string { + if i >= Status(len(_Status_index)-1) { + return fmt.Sprintf("Status(%d)", i) + } + return _Status_name[_Status_index[i]:_Status_index[i+1]] +} + +const _ErrorCode_name = "BadRequestEmailExistsInternalErrorInvalidCredentialsInvalidEmailInvalidOtpInvalidPasswordNeedOtpValidationNotAuthorizedNotFoundOtpAlreadySetupOtpNotSetupUserNotConfirmed" + +var _ErrorCode_index = [...]uint8{0, 10, 21, 34, 52, 64, 74, 89, 106, 119, 127, 142, 153, 169} + +func (i ErrorCode) String() string { + i -= 3 + if i >= ErrorCode(len(_ErrorCode_index)-1) { + return fmt.Sprintf("ErrorCode(%d)", i+3) + } + return _ErrorCode_name[_ErrorCode_index[i]:_ErrorCode_index[i+1]] +} diff --git a/api/logger.go b/api/logger.go new file mode 100644 index 0000000..7057a30 --- /dev/null +++ b/api/logger.go @@ -0,0 +1,27 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/jloup/utils" +) + +var log = utils.StandardL().WithField("module", "api") + +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + rawQuery := c.Request.URL.RawQuery + + c.Next() + + for _, err := range c.Errors { + l := log.WithField("path", path) + + if rawQuery != "" { + l = l.WithField("query", rawQuery) + } + + l.Errorf("%s", err.Err) + } + } +} diff --git a/api/market_config.go b/api/market_config.go new file mode 100644 index 0000000..3fd10ae --- /dev/null +++ b/api/market_config.go @@ -0,0 +1,77 @@ +package api + +import ( + "fmt" + + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" +) + +type MarketConfigQuery struct { + In struct { + User db.User + Market string + } +} + +func (q MarketConfigQuery) ValidateParams() *Error { + if q.In.Market != "poloniex" { + return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)} + } + + return nil +} + +func (q MarketConfigQuery) Run() (interface{}, *Error) { + config, err := db.GetUserMarketConfig(q.In.User.Id, q.In.Market) + if err != nil { + return nil, NewInternalError(err) + } + + if config == nil { + configMap := make(map[string]string) + configMap["key"] = "" + configMap["secret"] = "" + + config, err = db.SetUserMarketConfig(q.In.User.Id, q.In.Market, configMap) + if err != nil { + return nil, NewInternalError(err) + } + + } + + return config.Config, nil +} + +type UpdateMarketConfigQuery struct { + In struct { + User db.User + Market string + Key string + Secret string + } +} + +func (q UpdateMarketConfigQuery) ValidateParams() *Error { + if q.In.Market == "" { + return &Error{BadRequest, "invalid market name", fmt.Errorf("'%v' is not a valid market name", q.In.Market)} + } + + return nil +} + +func (q UpdateMarketConfigQuery) Run() (interface{}, *Error) { + configMap := make(map[string]string) + if q.In.Key != "" { + configMap["key"] = q.In.Key + } + if q.In.Secret != "" { + configMap["secret"] = q.In.Secret + } + + _, err := db.SetUserMarketConfig(q.In.User.Id, q.In.Market, configMap) + if err != nil { + return nil, NewInternalError(err) + } + + return nil, nil +} diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..d7e712c --- /dev/null +++ b/api/routes.go @@ -0,0 +1,123 @@ +package api + +import ( + "encoding/base64" + + "github.com/gin-gonic/gin" +) + +type Route struct { + Method string + Handlers []gin.HandlerFunc + Path string +} + +type Group struct { + Root string + Middlewares []Middleware + Routes []Route +} + +var Groups = []Group{ + { + "", + nil, + []Route{ + {"POST", []gin.HandlerFunc{Signup}, "/signup"}, + {"POST", []gin.HandlerFunc{Signin}, "/signin"}, + }, + }, + { + "/otp", + []Middleware{JwtAuth, UserConfirmed}, + []Route{ + {"GET", []gin.HandlerFunc{OtpEnrollment}, "/enroll"}, + {"POST", []gin.HandlerFunc{OtpValidate}, "/validate"}, + }, + }, + { + "/market", + []Middleware{JwtAuth, UserConfirmed, OtpAuth}, + []Route{ + {"GET", []gin.HandlerFunc{GetMarketConfig}, "/:name"}, + {"POST", []gin.HandlerFunc{UpdateMarketConfig}, "/:name/update"}, + }, + }, +} + +func Signup(c *gin.Context) { + query := &SignupQuery{} + + query.In.Email = c.PostForm("email") + query.In.Password = c.PostForm("password") + + RunQuery(query, c) +} + +func Signin(c *gin.Context) { + query := &SigninQuery{} + + query.In.Email = c.PostForm("email") + query.In.Password = c.PostForm("password") + + RunQuery(query, c) +} + +func OtpValidate(c *gin.Context) { + query := &OtpValidateQuery{} + + query.In.Pass = c.PostForm("pass") + query.In.User = GetUser(c) + query.In.Claims = GetClaims(c) + + RunQuery(query, c) +} + +func OtpEnrollment(c *gin.Context) { + query := &OtpEnrollmentQuery{} + + query.In.User = GetUser(c) + + qrPng, secret, err := query.Run() + if err != nil { + WriteJsonResponse(ErrorResponse(err.Code, err.UserMessage), c) + c.Error(err) + return + } + + if c.Query("format") == "png" { + c.Header("X-OTP-Secret", secret) + WriteBinary("image/png", qrPng.Bytes(), c) + } else { + response := struct { + Base64img string `json:"base64img"` + OtpSecret string `json:"secret"` + }{ + base64.StdEncoding.EncodeToString(qrPng.Bytes()), + secret, + } + + WriteJsonResponse(SuccessResponse(response), c) + } + +} + +func GetMarketConfig(c *gin.Context) { + query := &MarketConfigQuery{} + + query.In.User = GetUser(c) + query.In.Market = c.Param("name") + + RunQuery(query, c) +} + +func UpdateMarketConfig(c *gin.Context) { + query := &UpdateMarketConfigQuery{} + + query.In.User = GetUser(c) + query.In.Market = c.Param("name") + query.In.Key = c.PostForm("key") + query.In.Secret = c.PostForm("secret") + + RunQuery(query, c) +} diff --git a/api/user.go b/api/user.go new file mode 100644 index 0000000..4d4edba --- /dev/null +++ b/api/user.go @@ -0,0 +1,133 @@ +package api + +import ( + "fmt" + "regexp" + + "github.com/gin-gonic/gin" + + "immae.eu/Immae/Projets/Cryptomonnaies/Cryptoportfolio/Front/db" +) + +const ( + VALID_EMAIL_REGEX = `(?i)^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$` +) + +func IsValidEmailAddress(email string) bool { + r := regexp.MustCompile(VALID_EMAIL_REGEX) + + return r.MatchString(email) +} + +type SignParams struct { + Email string + Password string +} + +type SignResult struct { + Token string `json:"token"` +} + +func (s SignParams) Validate() *Error { + if !IsValidEmailAddress(s.Email) { + return &Error{InvalidEmail, "invalid email", fmt.Errorf("'%v' is not a valid email", s.Email)} + } + + if s.Password == "" { + return &Error{InvalidPassword, "invalid password", fmt.Errorf("invalid password")} + } + + return nil +} + +type SignupQuery struct { + In SignParams +} + +func (q SignupQuery) ValidateParams() *Error { + return q.In.Validate() +} + +func (q SignupQuery) Run() (interface{}, *Error) { + user, err := db.GetUserByEmail(q.In.Email) + if err != nil { + return nil, NewInternalError(err) + } + + if user != nil { + return nil, &Error{EmailExists, "email already taken", fmt.Errorf("'%v' is already registered '%v'", q.In.Email, user)} + } + + newUser := db.User{Email: q.In.Email, Status: db.AwaitingConfirmation} + newUser.PasswordHash, err = db.HashPassword(q.In.Password) + if err != nil { + return nil, NewInternalError(err) + } + + err = db.InsertUser(&newUser) + if err != nil { + return nil, NewInternalError(err) + } + + token, err := CreateJwtToken(newUser.Id) + if err != nil { + return nil, NewInternalError(fmt.Errorf("cannot create jwt token %v", err)) + } + + return SignResult{token}, nil +} + +type SigninQuery struct { + In SignParams +} + +func (q SigninQuery) ValidateParams() *Error { + return q.In.Validate() +} + +func (q SigninQuery) Run() (interface{}, *Error) { + user, err := db.GetUserByEmail(q.In.Email) + if err != nil { + return nil, NewInternalError(err) + } + + if user == nil { + return nil, &Error{InvalidCredentials, "invalid credentials", fmt.Errorf("no email '%v' found", q.In.Email)} + } + + err = db.ValidatePassword(q.In.Password, user.PasswordHash) + if err != nil { + return nil, &Error{InvalidCredentials, "invalid credentials", err} + } + + if user.Status != db.Confirmed { + return nil, &Error{UserNotConfirmed, "user awaiting admin validation", fmt.Errorf("user '%v' not confirmed", user)} + } + + token, err := CreateJwtToken(user.Id) + if err != nil { + return nil, NewInternalError(err) + } + + return SignResult{token}, nil +} + +func UserConfirmed(c *gin.Context) *Error { + user, exists := c.Get("user") + + if !exists { + return &Error{NotAuthorized, "not authorized", fmt.Errorf("no user key in context")} + } + + if user.(db.User).Status != db.Confirmed { + return &Error{UserNotConfirmed, "user awaiting admin validation", fmt.Errorf("user '%v' not confirmed", user)} + } + + return nil +} + +func GetUser(c *gin.Context) db.User { + user, _ := c.Get("user") + + return user.(db.User) +} -- cgit v1.2.3