From b20a53cc484df2ab8645a3edd7773bf6fb248dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20Delhaie?= Date: Sun, 8 May 2022 14:08:27 +0200 Subject: [PATCH] first commit --- .gitignore | 3 + .idea/.gitignore | 8 +++ .idea/misc.xml | 8 +++ .idea/modules.xml | 8 +++ .idea/opensavecloudserver.iml | 9 +++ authentication/authentication.go | 84 +++++++++++++++++++++++++++ config.default.yml | 9 +++ config/config.go | 52 +++++++++++++++++ constant/constant.go | 5 ++ database/database.go | 83 +++++++++++++++++++++++++++ database/model.go | 20 +++++++ go.mod | 18 ++++++ go.sum | 21 +++++++ main.go | 13 +++++ server/authentication.go | 64 +++++++++++++++++++++ server/data.go | 1 + server/response.go | 98 ++++++++++++++++++++++++++++++++ server/server.go | 78 +++++++++++++++++++++++++ server/system.go | 29 ++++++++++ 19 files changed, 611 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/opensavecloudserver.iml create mode 100644 authentication/authentication.go create mode 100644 config.default.yml create mode 100644 config/config.go create mode 100644 constant/constant.go create mode 100644 database/database.go create mode 100644 database/model.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 server/authentication.go create mode 100644 server/data.go create mode 100644 server/response.go create mode 100644 server/server.go create mode 100644 server/system.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e503885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.yml +cache/ +opensavecloudserver \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..283b9b4 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..1ac9f45 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/opensavecloudserver.iml b/.idea/opensavecloudserver.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/opensavecloudserver.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/authentication/authentication.go b/authentication/authentication.go new file mode 100644 index 0000000..f96242f --- /dev/null +++ b/authentication/authentication.go @@ -0,0 +1,84 @@ +package authentication + +import ( + "crypto/rand" + "errors" + "github.com/golang-jwt/jwt" + "golang.org/x/crypto/bcrypt" + "log" + "opensavecloudserver/database" + "time" +) + +var secret []byte + +type AccessToken struct { + Token string `json:"token"` +} + +type Registration struct { + Username string `json:"username"` + Password string `json:"password"` + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Pronouns int `json:"pronouns"` +} + +func init() { + secret = make([]byte, 512) + _, err := rand.Read(secret) + if err != nil { + log.Fatal(err) + } +} + +func Connect(username, password string) (*AccessToken, error) { + user, err := database.UserByUsername(username) + if err != nil { + return nil, err + } + if err := bcrypt.CompareHashAndPassword(user.Password, []byte(password)); err != nil { + return nil, err + } + token, err := token(user.ID) + if err != nil { + return nil, err + } + return &AccessToken{ + Token: token, + }, nil +} + +func ParseToken(token string) (int, error) { + var claims jwt.MapClaims + _, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil { + return 0, err + } + if userId, ok := claims["sub"]; ok { + return int(userId.(float64)), nil + } + return 0, errors.New("this token does not have a userId in it") +} + +func Register(user *Registration) error { + _, err := database.UserByUsername(user.Username) + if err == nil { + return errors.New("this username already exist") + } + hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), 12) + if err != nil { + return err + } + return database.AddUser(user.Username, user.Firstname, user.Lastname, hash, user.Pronouns) +} + +func token(userId int) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS512, jwt.MapClaims{ + "sub": userId, + "exp": time.Now().Add(1 * time.Hour).Unix(), + }) + return token.SignedString(secret) +} diff --git a/config.default.yml b/config.default.yml new file mode 100644 index 0000000..5d666ab --- /dev/null +++ b/config.default.yml @@ -0,0 +1,9 @@ +--- +database: + host: localhost + password: root + port: 3306 + username: root +features: + allow_register: false +cache: "/var/osc/cache" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..aac3ff1 --- /dev/null +++ b/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "flag" + "gopkg.in/yaml.v3" + "log" + "os" +) + +type Configuration struct { + Database DatabaseConfiguration `yaml:"database"` + Features FeaturesConfiguration `yaml:"features"` + Cache string `yaml:"cache"` +} + +type DatabaseConfiguration struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type FeaturesConfiguration struct { + AllowRegister bool `yaml:"allow_register"` +} + +var currentConfig *Configuration + +func init() { + path := flag.String("config", "./config.yml", "Set the configuration file path") + flag.Parse() + configYamlContent, err := os.ReadFile(*path) + if err != nil { + log.Fatal(err) + } + err = yaml.Unmarshal(configYamlContent, ¤tConfig) + if err != nil { + log.Fatalf("error: %s", err) + } +} + +func Database() *DatabaseConfiguration { + return ¤tConfig.Database +} + +func Features() *FeaturesConfiguration { + return ¤tConfig.Features +} + +func Cache() string { + return currentConfig.Cache +} diff --git a/constant/constant.go b/constant/constant.go new file mode 100644 index 0000000..161e1e0 --- /dev/null +++ b/constant/constant.go @@ -0,0 +1,5 @@ +package constant + +const Version = "1.0.0" + +const ApiVersion = 1 diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..458df08 --- /dev/null +++ b/database/database.go @@ -0,0 +1,83 @@ +package database + +import ( + "fmt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "log" + "opensavecloudserver/config" + "os" + "time" +) + +var db *gorm.DB + +func init() { + dbConfig := config.Database() + var err error + db, err = gorm.Open(mysql.Open( + fmt.Sprintf("%s:%s@tcp(%s:%d)/transagenda?charset=utf8mb4&parseTime=True&loc=Local", + dbConfig.Username, + dbConfig.Password, + dbConfig.Host, + dbConfig.Port), + ), &gorm.Config{ + Logger: logger.New( + log.New(os.Stdout, "", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: logger.Error, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + Colorful: true, // Enable color + }, + ), + }) + if err != nil { + log.Fatal(err) + } +} + +func UserByUsername(username string) (*User, error) { + var user *User + err := db.Model(User{}).Where(User{Username: username}).First(&user).Error + if err != nil { + return nil, err + } + return user, nil +} + +func UserById(userId int) (*User, error) { + var user *User + err := db.Model(User{}).Where(User{ID: userId}).First(&user).Error + if err != nil { + return nil, err + } + return user, nil +} + +func AddUser(username string, password []byte) error { + user := &User{ + Username: username, + Password: password, + } + return db.Save(user).Error +} + +func GameInfoById(userId, gameId int) (*Game, error) { + var game *Game + err := db.Model(Game{}).Where(Game{ID: gameId, UserId: userId}).First(&game).Error + if err != nil { + return nil, err + } + return game, nil +} + +func GameInfosByUserId(userId int) ([]*Game, error) { + var games []*Game + err := db.Model(Game{}).Where(Game{UserId: userId}).Find(&games).Error + if err != nil { + return nil, err + } + return games, nil +} diff --git a/database/model.go b/database/model.go new file mode 100644 index 0000000..142dcd1 --- /dev/null +++ b/database/model.go @@ -0,0 +1,20 @@ +package database + +import "time" + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password []byte `json:"-"` +} + +type Game struct { + ID int `json:"id"` + Name string `json:"name"` + Revision int `json:"rev"` + PathStorage string `json:"-"` + Hash string `json:"hash"` + LastUpdate time.Time `json:"last_update"` + UserId int `json:"-"` + Available bool `json:"available"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a77f7a2 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module opensavecloudserver + +go 1.18 + +require ( + github.com/go-chi/chi/v5 v5.0.7 + github.com/golang-jwt/jwt v3.2.2+incompatible + golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b + gorm.io/driver/mysql v1.3.3 + gorm.io/gorm v1.23.5 +) + +require ( + github.com/go-sql-driver/mysql v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.4 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..faefa0c --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 h1:NvGWuYG8dkDHFSKksI1P9faiVJ9rayE6l0+ouWVIDs8= +golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.3.3 h1:jXG9ANrwBc4+bMvBcSl8zCfPBaVoPyBEBshA8dA93X8= +gorm.io/driver/mysql v1.3.3/go.mod h1:ChK6AHbHgDCFZyJp0F+BmVGb06PSIoh9uVYKAlRbb2U= +gorm.io/gorm v1.23.1/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.23.5 h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM= +gorm.io/gorm v1.23.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..dd8b120 --- /dev/null +++ b/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "fmt" + "opensavecloudserver/constant" + "opensavecloudserver/server" + "runtime" +) + +func main() { + fmt.Printf("Open Save Cloud (Server) %s (%s %s)\n", constant.Version, runtime.GOOS, runtime.GOARCH) + server.Serve() +} diff --git a/server/authentication.go b/server/authentication.go new file mode 100644 index 0000000..8293d26 --- /dev/null +++ b/server/authentication.go @@ -0,0 +1,64 @@ +package server + +import ( + "encoding/json" + "io" + "log" + "net/http" + "opensavecloudserver/authentication" + "time" +) + +type Credential struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func Login(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + internalServerError(w, r) + log.Println(err) + return + } + credential := new(Credential) + err = json.Unmarshal(body, credential) + if err != nil { + internalServerError(w, r) + log.Println(err) + return + } + token, err := authentication.Connect(credential.Username, credential.Password) + if err != nil { + unauthorized(w, r) + return + } + ok(token, w, r) +} + +func Register(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + internalServerError(w, r) + log.Println(err) + return + } + registration := new(authentication.Registration) + err = json.Unmarshal(body, registration) + if err != nil { + internalServerError(w, r) + log.Println(err) + return + } + err = authentication.Register(registration) + if err != nil { + badRequest(err.Error(), w, r) + return + } + payload := successMessage{ + Message: "You are now registered", + Timestamp: time.Now(), + Status: 200, + } + ok(payload, w, r) +} diff --git a/server/data.go b/server/data.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/server/data.go @@ -0,0 +1 @@ +package server diff --git a/server/response.go b/server/response.go new file mode 100644 index 0000000..da81db3 --- /dev/null +++ b/server/response.go @@ -0,0 +1,98 @@ +package server + +import ( + "encoding/json" + "log" + "net/http" + "time" +) + +type httpError struct { + Status int `json:"status"` + Timestamp time.Time `json:"timestamp"` + Error string `json:"error"` + Message string `json:"message"` + Path string `json:"path"` +} + +type successMessage struct { + Status int `json:"status"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` +} + +func internalServerError(w http.ResponseWriter, r *http.Request) { + e := httpError{ + Status: 500, + Error: "Internal Server Error", + Message: "The server encountered an unexpected condition that prevented it from fulfilling the request.", + Path: r.RequestURI, + Timestamp: time.Now(), + } + + payload, err := json.Marshal(e) + if err != nil { + log.Println(err) + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(500) + _, err = w.Write(payload) + if err != nil { + log.Println(err) + } +} + +func unauthorized(w http.ResponseWriter, r *http.Request) { + e := httpError{ + Status: 401, + Error: "Unauthorized", + Message: "The request has not been completed because it lacks valid authentication credentials for the requested resource.", + Path: r.RequestURI, + Timestamp: time.Now(), + } + + payload, err := json.Marshal(e) + if err != nil { + log.Println(err) + } + w.Header().Add("Content-Type", "application/json") + w.Header().Add("WWW-Authenticate", "Custom realm=\"Login via /api/login\"") + w.WriteHeader(401) + _, err = w.Write(payload) + if err != nil { + log.Println(err) + } +} + +func ok(obj interface{}, w http.ResponseWriter, _ *http.Request) { + payload, err := json.Marshal(obj) + if err != nil { + log.Println(err) + } + w.Header().Add("Content-Type", "application/json") + _, err = w.Write(payload) + if err != nil { + log.Println(err) + } +} + +func badRequest(message string, w http.ResponseWriter, r *http.Request) { + e := httpError{ + Status: 400, + Error: "Bad Request", + Message: message, + Path: r.RequestURI, + Timestamp: time.Now(), + } + + payload, err := json.Marshal(e) + if err != nil { + log.Println(err) + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(400) + _, err = w.Write(payload) + if err != nil { + log.Println(err) + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..6e599e4 --- /dev/null +++ b/server/server.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "errors" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "log" + "net/http" + "opensavecloudserver/authentication" + "opensavecloudserver/config" +) + +type ContextKey string + +const UserIdKey ContextKey = "userId" + +// Serve start the http server +func Serve() { + router := chi.NewRouter() + router.Use(middleware.Logger) + router.Use(recovery) + router.Route("/api", func(r chi.Router) { + r.Post("/login", Login) + if config.Features().AllowRegister { + r.Post("/register", Register) + } + r.Route("/system", func(systemRouter chi.Router) { + systemRouter.Get("/information", Information) + }) + r.Group(func(secureRouter chi.Router) { + secureRouter.Use(authMiddleware) + }) + }) + log.Println("Server is listening...") + err := http.ListenAndServe(":8080", router) + if err != nil { + log.Fatal(err) + } +} + +// authMiddleware filter the request +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if len(header) > 7 { + userId, err := authentication.ParseToken(header[7:]) + if err != nil { + unauthorized(w, r) + return + } + ctx := context.WithValue(r.Context(), UserIdKey, userId) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + return + } + unauthorized(w, r) + }) +} + +func userIdFromContext(ctx context.Context) (int, error) { + if userId, ok := ctx.Value(UserIdKey).(int); ok { + return userId, nil + } + return 0, errors.New("userId not found in context") +} + +func recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + err := recover() + if err != nil { + internalServerError(w, r) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/server/system.go b/server/system.go new file mode 100644 index 0000000..80fb025 --- /dev/null +++ b/server/system.go @@ -0,0 +1,29 @@ +package server + +import ( + "net/http" + "opensavecloudserver/config" + "opensavecloudserver/constant" + "runtime" +) + +type information struct { + AllowRegister bool `json:"allow_register"` + Version string `json:"version"` + ApiVersion int `json:"api_version"` + GoVersion string `json:"go_version"` + OsName string `json:"os_name"` + OsArchitecture string `json:"os_architecture"` +} + +func Information(w http.ResponseWriter, r *http.Request) { + info := information{ + AllowRegister: config.Features().AllowRegister, + Version: constant.Version, + ApiVersion: constant.ApiVersion, + GoVersion: runtime.Version(), + OsName: runtime.GOOS, + OsArchitecture: runtime.GOARCH, + } + ok(info, w, r) +}