diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..36b8627 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.17-alpine + +WORKDIR . + +RUN go mod download + +RUN go build /cmd/api/main.go + +EXPOSE 3000 +ENTRYPOINT [ "main" ] \ No newline at end of file diff --git a/go.mod b/go.mod index 4cbca2b..ee3a199 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/joho/godotenv v1.3.0 github.com/labstack/gommon v0.3.0 + github.com/matoous/go-nanoid/v2 v2.0.0 golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac gorm.io/driver/mysql v1.1.2 gorm.io/gorm v1.21.15 diff --git a/go.sum b/go.sum index ab79839..c93d1e7 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,10 @@ github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/matoous/go-nanoid v1.5.0 h1:VRorl6uCngneC4oUQqOYtO3S0H5QKFtKuKycFG3euek= +github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= +github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0= +github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -67,8 +71,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= @@ -135,6 +140,8 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.1.2 h1:OofcyE2lga734MxwcCW9uB4mWNXMr50uaGRVwQL2B0M= gorm.io/driver/mysql v1.1.2/go.mod h1:4P/X9vSc3WTrhTLZ259cpFd6xKNYiSSdSZngkSBGIMM= gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= diff --git a/pkg/models/oauth_auth_code.go b/pkg/models/oauth_auth_code.go new file mode 100644 index 0000000..56adc36 --- /dev/null +++ b/pkg/models/oauth_auth_code.go @@ -0,0 +1,14 @@ +package models + +import "time" + +type OauthAuthCode struct { + ID string `json:"-" gorm:"primaryKey;type:varchar(100);unsigned;column:id;unique"` + ClientID uint `json:"client_id" gorm:"type:bigint(20);unsigned;column:client_id"` + Client OauthClient + Scopes string `json:"-" gorm:"type:varchar(200);column:scopes"` + UserAgent string `json:"-" gorm:"type:varchar(200);column:user_agent"` + State string `json:"-" gorm:"type:varchar(200);column:state;null"` + CreatedAt time.Time `json:"created_at" gorm:"type:timestamp;column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:timestamp;column:updated_at"` +} diff --git a/pkg/models/oauth_client.go b/pkg/models/oauth_client.go index 3cfb466..c180006 100644 --- a/pkg/models/oauth_client.go +++ b/pkg/models/oauth_client.go @@ -3,15 +3,16 @@ package models import "time" type OauthClient struct { - ID uint `json:"-" gorm:"primaryKey;type:bigint(20);unsigned;column:id"` - UserID int `json:"user_id" gorm:"primaryKey;type:bigint(20);unsigned;column:user_id"` - Name string `json:"name" gorm:"type:varchar(255);column:name"` - Secret string `json:"-" gorm:"type:varchar(100);column:secret"` - Provider string `json:"provider" gorm:"type:varchar(255);column:provider"` - Redirect string `json:" redirect" gorm:"type:varchar(65535);column:redirect"` - PersonalAccessClient string `json:"personal_access_client" gorm:"type:boolean;column:personal_access_client"` - PasswordClient string `json:"password_client" gorm:"type:boolean;column:password_client"` - Revoked string `json:"revoked" gorm:"type:boolean;column:revoked"` - CreatedAt time.Time `json:"created_at" gorm:"type:timestamp;column:created_at"` - UpdatedAt time.Time `json:"updated_at" gorm:"type:timestamp;column:updated_at"` + ID uint `json:"-" gorm:"primaryKey;type:bigint(20);unsigned;column:id"` + UserID int `json:"user_id" gorm:"primaryKey;type:bigint(20);unsigned;column:user_id"` + Name string `json:"name" gorm:"type:varchar(255);column:name"` + Secret string `json:"-" gorm:"type:varchar(100);column:secret"` + Redirect string `json:"redirect" gorm:"type:varchar(200);column:redirect"` + Revoked bool `json:"revoked" gorm:"type:boolean;column:revoked"` + CreatedAt time.Time `json:"created_at" gorm:"type:timestamp;column:created_at"` + UpdatedAt time.Time `json:"updated_at" gorm:"type:timestamp;column:updated_at"` +} + +func (client OauthClient) IsValidRedirectURI(uri string) bool { + return client.Redirect == uri } diff --git a/pkg/models/user.go b/pkg/models/user.go index 94a4207..6e1a6f4 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -8,7 +8,7 @@ type User struct { ID uint `json:"id" gorm:"primaryKey;type:bigint(20);unsigned;column:id"` NameFirst string `json:"name_first" gorm:"type:varchar(255);column:name_first"` NameLast string `json:"name_last" gorm:"type:varchar(255);column:name_last"` - Email string `json:"-" gorm:"type:varchar(255);column:email"` + Email string `json:"email" gorm:"type:varchar(255);column:email"` Rating int `json:"rating" gorm:"type:tinyint(4);column:rating"` PilotRating int `json:"pilot_rating" gorm:"type:tinyint(4);column:pilot_rating"` CountryID string `json:"country_id" gorm:"type:varchar(255);column:country_id"` diff --git a/pkg/oauth2/authorize.go b/pkg/oauth2/authorize.go new file mode 100644 index 0000000..bdaccbb --- /dev/null +++ b/pkg/oauth2/authorize.go @@ -0,0 +1,72 @@ +package oauth2 + +import ( + "api/internal/pkg/database" + "api/pkg/models" + "api/pkg/response" + "api/pkg/vatsim/connect" + "fmt" + "github.com/matoous/go-nanoid/v2" + "log" + "net/http" + "time" +) + +func Authorize(w http.ResponseWriter, r *http.Request) { + req, err := newRequest(r) + + if err != nil { + log.Println("Error occurred while creating a new request. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + client, err := req.Validate() + + if err != nil { + log.Println(err.Error()) + http.Redirect(w, r, fmt.Sprintf("%s?%s", r.Form.Get("redirect_uri"), err.Error()), http.StatusFound) + return + } + + id, err := gonanoid.New(100) + + if err != nil { + log.Println("Error occurred while generating the UID. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + login := models.OauthAuthCode{ + ID: id, + ClientID: client.ID, + Client: *client, + Scopes: req.Scopes, + UserAgent: r.UserAgent(), + State: req.State, + } + + if err := database.DB.Create(&login).Error; err != nil { + log.Println("Error occurred while saving the auth code. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + cookie := http.Cookie{ + Name: cookieName, + Value: id, + Path: "/oauth", + Domain: r.URL.Host, + Expires: time.Now().UTC().Add(time.Minute * 5), + Secure: true, + HttpOnly: true, + SameSite: 1, + } + + http.SetCookie(w, &cookie) + + connect.Login(w, r) +} diff --git a/pkg/oauth2/callback.go b/pkg/oauth2/callback.go new file mode 100644 index 0000000..b37ca0a --- /dev/null +++ b/pkg/oauth2/callback.go @@ -0,0 +1,62 @@ +package oauth2 + +import ( + "api/internal/pkg/database" + "api/pkg/models" + "api/pkg/response" + "api/pkg/vatsim/connect" + "fmt" + "log" + "net/http" + "net/url" + "time" +) + +func Callback(w http.ResponseWriter, r *http.Request) { + _, err := connect.Validate(w, r) + + if err != nil { + log.Printf("Failed to fetch user details. Error: %s.\n", err.Error()) + res := response.New(w, r, "Failed to fetch user details.", http.StatusInternalServerError) + res.Process() + return + } + + cookie, err := r.Cookie(cookieName) + + if err != nil { + log.Printf("Failed to fetch the cookie. Error: %s.\n", err.Error()) + res := response.New(w, r, "Failed to retrieve the token.", http.StatusInternalServerError) + res.Process() + return + } + + defer func() { + cookie = &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + Domain: r.URL.Host, + Expires: time.Now(), + } + + http.SetCookie(w, cookie) + }() + + authCode := models.OauthAuthCode{} + if err := database.DB.Preload("Client").Where("id = ? AND created_at > ?", cookie.Value, time.Now().UTC().Add(-time.Minute*5)).First(&authCode).Error; err != nil { + log.Printf("Failed to fetch the token. Error: %s.\n", err.Error()) + res := response.New(w, r, "Invalid token.", http.StatusInternalServerError) + res.Process() + return + } + + params := url.Values{} + params.Set("code", authCode.ID) + + if len(authCode.State) > 0 { + params.Set("state", authCode.State) + } + + http.Redirect(w, r, fmt.Sprintf("%s?%s", authCode.Client.Redirect, params.Encode()), http.StatusFound) +} diff --git a/pkg/oauth2/oauth.go b/pkg/oauth2/oauth.go index 36ebf62..eac812b 100644 --- a/pkg/oauth2/oauth.go +++ b/pkg/oauth2/oauth.go @@ -30,7 +30,7 @@ func User(w http.ResponseWriter, r *http.Request) { return } - bytes, err := connectJson(user) + bytes, err := connectJson(user, []string{"full_name"}) if err != nil { log.Printf("Error occurred while marshalling the response on /api/user. Error: %s.", err.Error()) @@ -47,20 +47,30 @@ func User(w http.ResponseWriter, r *http.Request) { } } -func connectJson(user models.User) ([]byte, error) { - res := connect.UserData{Data: connect.Data{ - CID: fmt.Sprintf("%d", user.ID), - Personal: connect.Personal{ - NameFirst: user.NameFirst, - NameLast: user.NameLast, - NameFull: fmt.Sprintf("%s %s", user.NameFirst, user.NameLast), - Email: user.Email, - Country: connect.Country{ - ID: user.CountryID, - Name: user.CountryName, - }, - }, - Vatsim: connect.Vatsim{ +func connectJson(user models.User, scopes []string) ([]byte, error) { + cUser := connect.UserData{} + + cUser.Data.CID = fmt.Sprintf("%d", user.ID) + + if isInScopes(scopes, "full_name") { + cUser.Data.Personal.NameFirst = user.NameFirst + cUser.Data.Personal.NameLast = user.NameLast + cUser.Data.Personal.NameFull = fmt.Sprintf("%s %s", user.NameFirst, user.NameLast) + } + + if isInScopes(scopes, "country") { + cUser.Data.Personal.Country = connect.Country{ + ID: user.CountryID, + Name: user.CountryName, + } + } + + if isInScopes(scopes, "email") { + cUser.Data.Personal.Email = user.Email + } + + if isInScopes(scopes, "vatsim_details") { + cUser.Data.Vatsim = connect.Vatsim{ Rating: connect.Rating{ ID: user.Rating, }, @@ -79,8 +89,18 @@ func connectJson(user models.User) ([]byte, error) { ID: user.SubdivisionID, Name: user.SubdivisionName, }, - }, - }} + } + } + + return json.Marshal(cUser) +} + +func isInScopes(scopes []string, scope string) bool { + for _, s := range scopes { + if scope == s { + return true + } + } - return json.Marshal(res) + return false } diff --git a/pkg/oauth2/token.go b/pkg/oauth2/token.go new file mode 100644 index 0000000..e4105ba --- /dev/null +++ b/pkg/oauth2/token.go @@ -0,0 +1,73 @@ +package oauth2 + +import ( + "api/pkg/response" + "api/utils" + "encoding/json" + "github.com/golang-jwt/jwt/v4" + "log" + "net/http" +) + +func Token(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + req, err := newAccessTokenRequest(r) + + if err != nil { + log.Println("Error occurred while creating a new request. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + _, aErr := req.Validate() + + if aErr != nil { + log.Println("Validation failed. Error:", aErr.internalError.Error()) + data, err := aErr.Json() + + if err != nil { + log.Println("Error occurred while marshalling the json response. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + w.WriteHeader(aErr.Code) + if _, err := w.Write(data); err != nil { + log.Println("failed to write") + return + } + } + + claims := jwt.MapClaims{ + "": "", + } + token, err := utils.GenerateNewJWT(claims) + + if err != nil { + log.Println("Error occurred while generating the JWT token. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + data := map[string]interface{}{ + "access_token": token, + "expires_in": 60 * 60 * 24 * 7, + } + + bytes, err := json.Marshal(data) + + if err != nil { + log.Println("Error occurred while marshalling the json response. Error:", err.Error()) + res := response.New(w, r, "Internal server error occurred.", http.StatusInternalServerError) + res.Process() + return + } + + w.WriteHeader(http.StatusOK) + w.Write(bytes) +} diff --git a/pkg/oauth2/types.go b/pkg/oauth2/types.go new file mode 100644 index 0000000..5573974 --- /dev/null +++ b/pkg/oauth2/types.go @@ -0,0 +1,280 @@ +package oauth2 + +import ( + "api/internal/pkg/database" + "api/pkg/models" + "api/utils" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" +) + +const ( + cookieName = "token" + contentType = "application/x-www-form-urlencoded" +) + +var scopes = []string{"full_name", "email", "vatsim_details", "country"} + +type authorizationRequest struct { + ResponseType, ClientID, RedirectURI, State, Scopes string +} + +type accessTokenError struct { + Err string `json:"error"` + Description string `json:"error_description"` + Code int `json:"-"` + internalError error +} + +func (err *accessTokenError) Json() ([]byte, error) { + return json.Marshal(err) +} + +type requestError struct { + Response url.Values +} + +func (err requestError) Error() string { + return err.Response.Encode() +} + +func newRequest(r *http.Request) (*authorizationRequest, error) { + if r == nil { + return nil, errors.New("please provide a valid request") + } + + if err := r.ParseForm(); err != nil { + return nil, err + } + + return &authorizationRequest{ + ResponseType: r.Form.Get("response_type"), + ClientID: r.Form.Get("client_id"), + RedirectURI: r.Form.Get("redirect_uri"), + Scopes: r.Form.Get("scope"), + State: r.Form.Get("state"), + }, nil +} + +func (request *authorizationRequest) Validate() (*models.OauthClient, error) { + if len(request.ClientID) < 1 || len(request.ResponseType) < 1 || len(request.RedirectURI) < 1 { + return nil, requestError{ + Response: request.formatURL("invalid_request"), + } + } + + if request.ResponseType != "code" { + return nil, requestError{Response: request.formatURL("unsupported_response_type")} + } + + s := strings.Split(request.Scopes, " ") + + // also ensure the first element is an empty string because strings.Split returns an empty string + if len(s) > 0 && s[0] != "" { + for _, scope := range s { + if !request.isValidScope(scope) { + return nil, requestError{Response: request.formatURL("invalid_scope")} + } + } + } else { + request.Scopes = strings.Join(scopes, " ") + } + + client := models.OauthClient{} + if err := database.DB.Where("id = ?", request.ClientID).First(&client).Error; err != nil { + return nil, requestError{Response: request.formatURL("unauthorized_client")} + } + + if client.Revoked { + return nil, requestError{Response: request.formatURL("unauthorized_client")} + } + + if !client.IsValidRedirectURI(request.RedirectURI) { + return nil, requestError{Response: request.formatURL("unauthorized_client")} + } + + return &client, nil +} + +func (request authorizationRequest) formatURL(err string) url.Values { + val := url.Values{} + val.Set("error", err) + val.Set("error_description", getError(err)) + + if len(request.State) > 0 { + val.Set("state", request.State) + } + + return val +} + +func getError(err string) string { + authErrors := map[string]string{ + "invalid_request": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.", + "unauthorized_client": "The client is not authorized to request an authorization code using this method.", + "access_denied": "The resource owner or authorization server denied the request.", + "unsupported_response_type": "The authorization server does not support obtaining an authorization code using this method.", + "invalid_scope": "The requested scope is invalid, unknown, or malformed.", + "server_error": "The authorization server encountered an unexpected condition that prevented it from fulfilling the request.", + "temporarily_unavailable": "The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.", + "invalid_grant": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.", + "invalid_client": "Client authentication failed.", + "unsupported_grant_type": "The authorization grant type is not supported by the authorization server.", + } + + e, ok := authErrors[err] + + if !ok { + return "Error occurred." + } + + return e +} + +func (request authorizationRequest) isValidScope(scope string) bool { + for _, availableScope := range scopes { + if availableScope == scope { + return true + } + } + + return false +} + +type accessTokenRequest struct { + ContentType, GrantType, Code, RedirectURI, ClientID, ClientSecret, State string +} + +func newAccessTokenRequest(r *http.Request) (*accessTokenRequest, error) { + if r == nil { + return nil, errors.New("please provide a valid request") + } + + if err := r.ParseForm(); err != nil { + return nil, err + } + + return &accessTokenRequest{ + ContentType: r.Header.Get("Content-type"), + GrantType: r.PostForm.Get("grant_type"), + Code: r.PostForm.Get("code"), + RedirectURI: r.PostForm.Get("redirect_uri"), + ClientID: r.PostForm.Get("client_id"), + ClientSecret: r.PostForm.Get("client_secret"), + State: r.PostForm.Get("state"), + }, nil +} + +func (r accessTokenRequest) Validate() (*models.OauthClient, *accessTokenError) { + if !r.hasRequiredAttributes() { + return nil, &accessTokenError{ + Err: "invalid_request", + Code: http.StatusBadRequest, + Description: getError("invalid_request"), + internalError: errors.New("required parameters weren't provided"), + } + } + + if r.ContentType != contentType { + return nil, &accessTokenError{ + Err: "invalid_request", + Code: http.StatusBadRequest, + Description: "Invalid content type provided", + internalError: errors.New("invalid content type provided"), + } + } + + if r.GrantType != "authorization_code" { + return nil, &accessTokenError{ + Err: "invalid_grant", + Code: http.StatusBadRequest, + Description: getError("invalid_grant"), + internalError: errors.New("invalid grant type provided"), + } + } + + code := models.OauthAuthCode{} + if err := database.DB.Where("id = ?", r.Code).First(&code).Error; err != nil { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("auth code not found"), + } + } + + client := models.OauthClient{} + if err := database.DB.Where("id = ? AND secret = ?", r.ClientID, r.ClientSecret).First(&client).Error; err != nil { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("client not found"), + } + } + + if client.Revoked { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("client is revoked"), + } + } + + if client.Secret != r.ClientSecret { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("client secret does not match"), + } + } + + if client.ID != code.ClientID { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("auth code client and provided client don't match not found"), + } + } + + if !client.IsValidRedirectURI(r.RedirectURI) { + return nil, &accessTokenError{ + Err: "invalid_client", + Code: http.StatusUnauthorized, + Description: getError("invalid_client"), + internalError: errors.New("invalid redirect URI"), + } + } + + return &client, nil +} + +func (r accessTokenRequest) hasRequiredAttributes() bool { + if utils.IsEmptyString(r.GrantType) { + return false + } + + if utils.IsEmptyString(r.Code) { + return false + } + + if utils.IsEmptyString(r.RedirectURI) { + return false + } + + if utils.IsEmptyString(r.ClientSecret) { + return false + } + + if utils.IsEmptyString(r.ClientID) { + return false + } + + return true +} diff --git a/pkg/vatsim/connect/types.go b/pkg/vatsim/connect/types.go index f05baba..b98f23d 100644 --- a/pkg/vatsim/connect/types.go +++ b/pkg/vatsim/connect/types.go @@ -16,54 +16,54 @@ type UserData struct { type Data struct { CID string `json:"cid"` - Personal Personal `json:"personal"` - Vatsim Vatsim `json:"vatsim"` + Personal Personal `json:"personal,omitempty"` + Vatsim Vatsim `json:"vatsim,omitempty"` } type Personal struct { - NameFirst string `json:"name_first"` - NameLast string `json:"name_last"` - NameFull string `json:"name_full"` - Email string `json:"email"` - Country Country `json:"country"` + NameFirst string `json:"name_first,omitempty"` + NameLast string `json:"name_last,omitempty"` + NameFull string `json:"name_full,omitempty"` + Email string `json:"email,omitempty"` + Country Country `json:"country,omitempty"` } type Country struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } type Vatsim struct { - Rating Rating `json:"rating"` - PilotRating PilotRating `json:"pilotrating"` - Region Region `json:"region"` - Division Division `json:"division"` - Subdivision Subdivision `json:"subdivision"` + Rating Rating `json:"rating,omitempty"` + PilotRating PilotRating `json:"pilotrating,omitempty"` + Region Region `json:"region,omitempty"` + Division Division `json:"division,omitempty"` + Subdivision Subdivision `json:"subdivision,omitempty"` } type Rating struct { - ID int `json:"id"` - Long string `json:"long"` - Short string `json:"short"` + ID int `json:"id,omitempty"` + Long string `json:"long,omitempty"` + Short string `json:"short,omitempty"` } type PilotRating struct { - ID int `json:"id"` - Long string `json:"long"` - Short string `json:"short"` + ID int `json:"id,omitempty"` + Long string `json:"long,omitempty"` + Short string `json:"short,omitempty"` } type Division struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } type Subdivision struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } type Region struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` } diff --git a/pkg/vatsim/connect/validate.go b/pkg/vatsim/connect/validate.go index 7d924e1..80cd055 100644 --- a/pkg/vatsim/connect/validate.go +++ b/pkg/vatsim/connect/validate.go @@ -3,12 +3,10 @@ package connect import ( "api/internal/pkg/database" "api/pkg/models" - "api/pkg/response" "api/utils" "encoding/json" "errors" "fmt" - "github.com/golang-jwt/jwt/v4" "gorm.io/gorm" "io" "log" @@ -23,21 +21,16 @@ const ( grantType = "authorization_code" ) -func Validate(w http.ResponseWriter, r *http.Request) { +func Validate(w http.ResponseWriter, r *http.Request) (*UserData, error) { if err := r.URL.Query().Get("error"); len(err) > 0 { e := getError(err) - - res := response.New(w, r, e, http.StatusExpectationFailed) - res.Process() - return + return nil, errors.New(e) } code := r.URL.Query().Get("code") if len(code) < 0 { - res := response.New(w, r, "Code was not provided.", http.StatusBadRequest) - res.Process() - return + return nil, errors.New("code was not provided") } tokenChannel := make(chan Token) @@ -45,10 +38,7 @@ func Validate(w http.ResponseWriter, r *http.Request) { token := <-tokenChannel if token.err != nil { - log.Printf("Internal server error occurred while fetching the user details 44. Error: %s.", token.err.Error()) - res := response.New(w, r, "Internal server error occurred while fetching the user details 44.", http.StatusInternalServerError) - res.Process() - return + return nil, errors.New("failed to fetch the access token") } userChannel := make(chan UserData) @@ -56,23 +46,17 @@ func Validate(w http.ResponseWriter, r *http.Request) { user := <-userChannel if user.err != nil { - log.Printf("Internal server error occurred while fetching the user details 55. Error: %s.", user.err.Error()) - res := response.New(w, r, "Internal server error occurred while fetching the user details 55.", http.StatusInternalServerError) - res.Process() - return + return nil, errors.New("failed to fetch the user details") } saveChannel := make(chan error) go saveUser(user.Data, saveChannel) if err := <-saveChannel; err != nil { - log.Printf("Internal server error occurred while fetching the user details 66. Error: %s.", err.Error()) - res := response.New(w, r, "Internal server error occurred while fetching the user details 66.", http.StatusInternalServerError) - res.Process() - return + return nil, errors.New("failed to store the user") } - claims := jwt.MapClaims{ + /*claims := jwt.MapClaims{ "cid": user.Data.CID, } @@ -104,7 +88,9 @@ func Validate(w http.ResponseWriter, r *http.Request) { res := response.New(w, r, "Internal server error occurred while fetching the user details 102.", http.StatusInternalServerError) res.Process() return - } + }*/ + + return &user, nil } func getToken(code string, tokenChannel chan Token) { diff --git a/pkg/web/types.go b/pkg/web/types.go index 8059264..cfcced7 100644 --- a/pkg/web/types.go +++ b/pkg/web/types.go @@ -84,12 +84,12 @@ func (server *Server) loadRoutes() { []string{ "GET", }, - connect.Validate, + oauth2.User, Permission{ - false, - true, - false, - false, + AuthNeeded: false, + GuestOnly: false, + AllowCors: false, + SubdivisionToken: false, }, }, { @@ -391,6 +391,45 @@ func (server *Server) loadRoutes() { true, }, }, + { + "/oauth/authorize", + []string{ + "GET", + }, + oauth2.Authorize, + Permission{ + false, + true, + false, + false, + }, + }, + { + "/oauth/callback", + []string{ + "GET", + }, + oauth2.Callback, + Permission{ + false, + true, + false, + false, + }, + }, + { + "/oauth/token", + []string{ + "POST", + }, + oauth2.Token, + Permission{ + false, + true, + false, + false, + }, + }, } } diff --git a/swagger.json b/swagger.json new file mode 100644 index 0000000..60e3fe9 --- /dev/null +++ b/swagger.json @@ -0,0 +1,210 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "description": "A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "Swagger API Team", + "email": "apiteam@swagger.io", + "url": "http://swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "host": "petstore.swagger.io", + "basePath": "/api", + "schemes": [ + "http" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/pets": { + "get": { + "description": "Returns all pets from the system that the user has access to\nNam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.\n\nSed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.\n", + "operationId": "findPets", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "tags to filter by", + "required": false, + "type": "array", + "collectionFormat": "csv", + "items": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "maximum number of results to return", + "required": false, + "type": "integer", + "format": "int32" + } + ], + "responses": { + "200": { + "description": "pet response", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "default": { + "description": "unexpected error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "post": { + "description": "Creates a new pet in the store. Duplicates are allowed", + "operationId": "addPet", + "parameters": [ + { + "name": "pet", + "in": "body", + "description": "Pet to add to the store", + "required": true, + "schema": { + "$ref": "#/definitions/NewPet" + } + } + ], + "responses": { + "200": { + "description": "pet response", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "default": { + "description": "unexpected error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, + "/pets/{id}": { + "get": { + "description": "Returns a user based on a single ID, if the user does not have access to the pet", + "operationId": "find pet by id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of pet to fetch", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "pet response", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "default": { + "description": "unexpected error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "delete": { + "description": "deletes a single pet based on the ID supplied", + "operationId": "deletePet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of pet to delete", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "204": { + "description": "pet deleted" + }, + "default": { + "description": "unexpected error", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/NewPet" + }, + { + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + ] + }, + "NewPet": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/utils/empty.go b/utils/empty.go new file mode 100644 index 0000000..1d91a7c --- /dev/null +++ b/utils/empty.go @@ -0,0 +1,5 @@ +package utils + +func IsEmptyString(data string) bool { + return len(data) < 1 +}