diff --git a/cmd/imagini.db b/cmd/imagini.db index ecb2d8f..9320e9a 100644 Binary files a/cmd/imagini.db and b/cmd/imagini.db differ diff --git a/cmd/server/server.go b/cmd/server/server.go index f089fae..d010fcb 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -4,11 +4,12 @@ import ( "time" "context" "net/http" + log "github.com/sirupsen/logrus" + "reichard.io/imagini/internal/db" "reichard.io/imagini/internal/api" "reichard.io/imagini/internal/auth" "reichard.io/imagini/internal/config" - log "github.com/sirupsen/logrus" ) type Server struct { diff --git a/internal/api/auth.go b/internal/api/auth.go index 87387d9..dcde5b7 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -13,11 +13,6 @@ import ( "reichard.io/imagini/internal/models" ) -// https://www.calhoun.io/pitfalls-of-context-values-and-how-to-avoid-or-mitigate-them/ -// https://pace.dev/blog/2018/05/09/how-I-write-http-services-after-eight-years.html -// https://medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1#333c -// https://www.alexedwards.net/blog/organising-database-access <---- best -// - TLDR: Do what you're doing, but use closeures for the handlers func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { errorJSON(w, "Method is not supported.", http.StatusMethodNotAllowed) @@ -38,34 +33,6 @@ func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) { return } - // Verify Device Name Exists - deviceHeader := r.Header.Get("X-Imagini-DeviceName") - if deviceHeader == "" { - errorJSON(w, "Missing 'X-Imagini-DeviceName' header.", http.StatusBadRequest) - return - } - - // Derive Device Type - var deviceType string - userAgent := strings.ToLower(r.Header.Get("User-Agent")) - if strings.HasPrefix(userAgent, "ios-imagini"){ - deviceType = "iOS" - } else if strings.HasPrefix(userAgent, "android-imagini"){ - deviceType = "Android" - } else if strings.HasPrefix(userAgent, "chrome"){ - deviceType = "Chrome" - } else if strings.HasPrefix(userAgent, "firefox"){ - deviceType = "Firefox" - } else if strings.HasPrefix(userAgent, "msie"){ - deviceType = "Internet Explorer" - } else if strings.HasPrefix(userAgent, "edge"){ - deviceType = "Edge" - } else if strings.HasPrefix(userAgent, "safari"){ - deviceType = "Safari" - }else { - deviceType = "Unknown" - } - // Do login resp, user := api.Auth.AuthenticateUser(creds) if !resp { @@ -73,8 +40,13 @@ func (api *API) loginHandler(w http.ResponseWriter, r *http.Request) { return } - // Create New Device - device, err := api.DB.CreateDevice(models.Device{Name: deviceHeader, Type: deviceType}) + // Upsert device + device, err := api.upsertRequestedDevice(user, r) + if err != nil { + log.Error("[api] loginHandler - Failed to upsert device: ", err) + errorJSON(w, "DB error. Unable to proceed.", http.StatusUnauthorized) + return + } // Create Tokens accessToken, err := api.Auth.CreateJWTAccessToken(user, device) @@ -96,19 +68,13 @@ func (api *API) logoutHandler(w http.ResponseWriter, r *http.Request) { return } - // Do logout + // TODO: Reset Refresh Key - // TODO: Clear Session Server Side + // Clear Cookies + http.SetCookie(w, &http.Cookie{Name: "AccessToken", Expires: time.Unix(0, 0)}) + http.SetCookie(w, &http.Cookie{Name: "RefreshToken", Expires: time.Unix(0, 0)}) - // Tell Client to Expire Token - cookie := &http.Cookie{ - Name: "Token", - Value: "", - Path: "/", - Expires: time.Unix(0, 0), - HttpOnly: true, - } - http.SetCookie(w, cookie) + successJSON(w, "Logout success.", http.StatusOK) } func (api *API) refreshLoginHandler(w http.ResponseWriter, r *http.Request) { @@ -162,3 +128,96 @@ func (api *API) refreshLoginHandler(w http.ResponseWriter, r *http.Request) { // Response success successJSON(w, "Refresh success.", http.StatusOK) } + +/** + * This will find or create the requested device based on ID and User. + **/ +func (api *API) upsertRequestedDevice(user models.User, r *http.Request) (models.Device, error) { + requestedDevice := deriveRequestedDevice(r) + requestedDevice.Type = deriveDeviceType(r) + requestedDevice.User = user + + if requestedDevice.UUID == uuid.Nil { + createdDevice, err := api.DB.CreateDevice(requestedDevice) + return createdDevice, err + } + + foundDevice, err := api.DB.Device(models.Device{ + Base: models.Base{ UUID: requestedDevice.UUID }, + User: user, + }) + + return foundDevice, err +} + +func deriveDeviceType(r *http.Request) string { + userAgent := strings.ToLower(r.Header.Get("User-Agent")) + if strings.HasPrefix(userAgent, "ios-imagini"){ + return "iOS" + } else if strings.HasPrefix(userAgent, "android-imagini"){ + return "Android" + } else if strings.HasPrefix(userAgent, "chrome"){ + return "Chrome" + } else if strings.HasPrefix(userAgent, "firefox"){ + return "Firefox" + } else if strings.HasPrefix(userAgent, "msie"){ + return "Internet Explorer" + } else if strings.HasPrefix(userAgent, "edge"){ + return "Edge" + } else if strings.HasPrefix(userAgent, "safari"){ + return "Safari" + } + return "Unknown" +} + +func deriveRequestedDevice(r *http.Request) models.Device { + deviceSkeleton := models.Device{} + authHeader := r.Header.Get("X-Imagini-Authorization") + splitAuthInfo := strings.Split(authHeader, ",") + + // For each Key - Value pair + for i := range splitAuthInfo { + + // Split Key - Value + item := strings.TrimSpace(splitAuthInfo[i]) + splitItem := strings.SplitN(item, "=", 2) + if len(splitItem) != 2 { + continue + } + + // Derive Key + key := strings.ToLower(strings.TrimSpace(splitItem[0])) + if key != "deviceuuid" && key != "devicename" { + continue + } + + // Derive Value + val := trimQuotes(strings.ToLower(strings.TrimSpace(splitItem[1]))) + if key == "deviceuuid" { + parsedDeviceUUID, err := uuid.Parse(val) + if err != nil { + log.Warn("[auth] deriveRequestedDevice - Unable to parse requested DeviceUUID: ", val) + continue + } + deviceSkeleton.Base = models.Base{UUID: parsedDeviceUUID} + } else if key == "devicename" { + deviceSkeleton.Name = val + } + } + + // If name not set, set to type + if deviceSkeleton.Name == "" { + deviceSkeleton.Name = deviceSkeleton.Type + } + + return deviceSkeleton +} + +func trimQuotes(s string) string { + if len(s) >= 2 { + if s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/internal/api/devices.go b/internal/api/devices.go new file mode 100644 index 0000000..70a718e --- /dev/null +++ b/internal/api/devices.go @@ -0,0 +1,9 @@ +package api + +import ( + "net/http" +) + +func (api *API) devicesHandler(w http.ResponseWriter, r *http.Request) { + +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 53817bf..918eae5 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -6,40 +6,46 @@ import ( ) func (api *API) registerRoutes() { - api.Router.HandleFunc("/MediaItems", multipleMiddleware( + api.Router.HandleFunc("/api/v1/MediaItems", multipleMiddleware( api.mediaItemsHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Upload", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Devices", multipleMiddleware( + api.devicesHandler, + api.authMiddleware, + )) + api.Router.HandleFunc("/api/v1/Upload", multipleMiddleware( api.uploadHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Albums", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Albums", multipleMiddleware( api.albumsHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Users", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Users", multipleMiddleware( api.usersHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Tags", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Tags", multipleMiddleware( api.tagsHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Info", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Info", multipleMiddleware( api.infoHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Me", multipleMiddleware( + api.Router.HandleFunc("/api/v1/Me", multipleMiddleware( api.meHandler, api.authMiddleware, )) - api.Router.HandleFunc("/Logout", api.logoutHandler) - api.Router.HandleFunc("/Login", api.loginHandler) - api.Router.HandleFunc("/RefreshLogin", api.refreshLoginHandler) + api.Router.HandleFunc("/api/v1/Logout", api.logoutHandler) + api.Router.HandleFunc("/api/v1/Login", api.loginHandler) + api.Router.HandleFunc("/api/v1/RefreshLogin", api.refreshLoginHandler) } + + // https://stackoverflow.com/a/59764037 func errorJSON(w http.ResponseWriter, err string, code int) { w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/api/users.go b/internal/api/users.go index 0aa6758..4c28349 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -2,7 +2,7 @@ package api import ( "net/http" - log "github.com/sirupsen/logrus" + // log "github.com/sirupsen/logrus" ) func (api *API) usersHandler(w http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d26e84c..720aa2d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -62,26 +62,11 @@ func (auth *AuthManager) AuthenticateUser(creds models.APICredentials) (bool, mo } } - func (auth *AuthManager) getRole(user models.User) string { // TODO: Lookup role of user return "User" } -func (auth *AuthManager) ValidateJWTAccessToken(accessJWT string) (jwt.Token, bool) { - byteAccessJWT := []byte(accessJWT) - verifiedToken, err := jwt.ParseBytes(byteAccessJWT, jwt.WithVerify(jwa.HS256, []byte(auth.Config.JWTSecret))) - if err != nil { - fmt.Println("failed to parse payload: ", err) - return nil, false - } - return verifiedToken, true -} - -func (auth *AuthManager) RevokeRefreshToken() { - -} - func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, bool) { byteRefreshJWT := []byte(refreshJWT) @@ -100,8 +85,11 @@ func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, return nil, false } - // Verify Token - verifiedToken, err := jwt.ParseBytes(byteRefreshJWT, jwt.WithVerify(jwa.HS256, []byte(device.RefreshKey))) + // Verify & Validate Token + verifiedToken, err := jwt.ParseBytes(byteRefreshJWT, + jwt.WithValidate(true), + jwt.WithVerify(jwa.HS256, []byte(device.RefreshKey)), + ) if err != nil { fmt.Println("failed to parse payload: ", err) return nil, false @@ -109,11 +97,17 @@ func (auth *AuthManager) ValidateJWTRefreshToken(refreshJWT string) (jwt.Token, return verifiedToken, true } -func (auth *AuthManager) UpdateRefreshToken(deviceID string) error { - // TODO: - // - Remove Refresh token from Session AND DB - // - Call CreateRefreshToken - return nil +func (auth *AuthManager) ValidateJWTAccessToken(accessJWT string) (jwt.Token, bool) { + byteAccessJWT := []byte(accessJWT) + verifiedToken, err := jwt.ParseBytes(byteAccessJWT, + jwt.WithValidate(true), + jwt.WithVerify(jwa.HS256, []byte(auth.Config.JWTSecret)), + ) + if err != nil { + fmt.Println("failed to parse payload: ", err) + return nil, false + } + return verifiedToken, true } func (auth *AuthManager) CreateJWTRefreshToken(user models.User, device models.Device) (string, error) { @@ -123,13 +117,15 @@ func (auth *AuthManager) CreateJWTRefreshToken(user models.User, device models.D // Create New Token tm := time.Now() t := jwt.New() - t.Set(`did`, device.UUID) // Device ID - t.Set(jwt.SubjectKey, user.UUID) // User ID - t.Set(jwt.AudienceKey, `imagini`) // App ID - t.Set(jwt.IssuedAtKey, tm) // Issued At + t.Set(`did`, device.UUID.String()) // Device ID + t.Set(jwt.SubjectKey, user.UUID.String()) // User ID + t.Set(jwt.AudienceKey, `imagini`) // App ID + t.Set(jwt.IssuedAtKey, tm) // Issued At - // TODO: Depends on Device - t.Set(jwt.ExpirationKey, tm.Add(time.Hour * 24)) // 1 Day Access Key + // iOS & Android = Never Expiring Refresh Token + if device.Type != "iOS" && device.Type != "Android" { + t.Set(jwt.ExpirationKey, tm.Add(time.Hour * 24)) // 1 Day Access Key + } // Validate Token Creation _, err := json.MarshalIndent(t, "", " ") @@ -150,18 +146,15 @@ func (auth *AuthManager) CreateJWTRefreshToken(user models.User, device models.D } func (auth *AuthManager) CreateJWTAccessToken(user models.User, device models.Device) (string, error) { - // Acquire Role - role := auth.getRole(user) - // Create New Token tm := time.Now() t := jwt.New() - t.Set(`did`, device.UUID) // Device ID - t.Set(`role`, role) // User Role (Admin / User) - t.Set(jwt.SubjectKey, user.UUID) // User ID + t.Set(`did`, device.UUID.String()) // Device ID + t.Set(`role`, auth.getRole(user)) // User Role (Admin / User) + t.Set(jwt.SubjectKey, user.UUID.String()) // User ID t.Set(jwt.AudienceKey, `imagini`) // App ID t.Set(jwt.IssuedAtKey, tm) // Issued At - t.Set(jwt.ExpirationKey, tm.Add(time.Minute * 30)) // 30 Minute Access Key + t.Set(jwt.ExpirationKey, tm.Add(time.Hour * 2)) // 2 Hour Access Key // Validate Token Creation _, err := json.MarshalIndent(t, "", " ") diff --git a/internal/db/albums.go b/internal/db/albums.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/db/db.go b/internal/db/db.go index 214ef08..23245c2 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -62,25 +62,3 @@ func (dbm *DBManager) bootstrapDatabase() { log.Fatal("[query] Unable to bootstrap database.") } } - -func (dbm *DBManager) ItemsFromAlbum(user models.User, album models.Album) []models.MediaItem { - var mediaItems []models.MediaItem - // db.Table("media_albums"). - // Select("media_item.*"). - // Joins("INNER JOIN media_items ON media_albums.ID = media_items.Albums"). - // Where("media_albums.album_id = ? AND media_items.User = ?", albumID, userID). - - - dbm.db. - //Where("album = ? AND user = ?", albumID, userID). - Find(&mediaItems) - return mediaItems - - // db.Raw(` - // SELECT - // MediaItems.* - // FROM - // MediaAlbums - // INNER JOIN MediaItems ON MediaAlbums.mediaID = MediaItems.mediaID - // WHERE MediaAlbums.albumID = ? AND MediaItems.userID = ?`, albumID, userID) -} diff --git a/internal/db/devices.go b/internal/db/devices.go index 1a00bb2..a99f7bf 100644 --- a/internal/db/devices.go +++ b/internal/db/devices.go @@ -7,8 +7,8 @@ import ( "reichard.io/imagini/internal/models" ) -func (dbm *DBManager) CreateDevice(device models.Device) (models.Device, error) { - log.Info("[query] Creating device: ", device.Name) +func (dbm *DBManager) CreateDevice (device models.Device) (models.Device, error) { + log.Info("[db] Creating device: ", device.Name) device.RefreshKey = uuid.New().String() err := dbm.db.Create(&device).Error return device, err diff --git a/internal/db/media_items.go b/internal/db/media_items.go new file mode 100644 index 0000000..26b025b --- /dev/null +++ b/internal/db/media_items.go @@ -0,0 +1,35 @@ +package db + +import ( + log "github.com/sirupsen/logrus" + + "reichard.io/imagini/internal/models" +) + +func (dbm *DBManager) CreateMediaItem (mediaItem models.MediaItem) (models.MediaItem, error) { + log.Info("[db] Creating media item: ", mediaItem.RelPath) + err := dbm.db.Create(&mediaItem).Error + return mediaItem, err +} + +func (dbm *DBManager) MediaItemsFromAlbum(user models.User, album models.Album) ([]models.MediaItem, error) { + var mediaItems []models.MediaItem + // db.Table("media_albums"). + // Select("media_item.*"). + // Joins("INNER JOIN media_items ON media_albums.ID = media_items.Albums"). + // Where("media_albums.album_id = ? AND media_items.User = ?", albumID, userID). + + + err := dbm.db. + //Where("album = ? AND user = ?", albumID, userID). + Find(&mediaItems).Error + return mediaItems, err + + // db.Raw(` + // SELECT + // MediaItems.* + // FROM + // MediaAlbums + // INNER JOIN MediaItems ON MediaAlbums.mediaID = MediaItems.mediaID + // WHERE MediaAlbums.albumID = ? AND MediaItems.userID = ?`, albumID, userID) +} diff --git a/internal/db/tags.go b/internal/db/tags.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/db/users.go b/internal/db/users.go index 6952ba3..0215f50 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -8,7 +8,7 @@ import ( ) func (dbm *DBManager) CreateUser(user models.User) (models.User, error) { - log.Info("[query] Creating user: ", user.Username) + log.Info("[db] Creating user: ", user.Username) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { log.Error(err) diff --git a/internal/models/db.go b/internal/models/db.go index 46e5463..1a396d4 100644 --- a/internal/models/db.go +++ b/internal/models/db.go @@ -28,9 +28,9 @@ type ServerSetting struct { type Device struct { Base - User User `json:"user" gorm:"ForeignKey:UUID"` - Name string `json:"name"` - Type string `json:"type"` // Android, iOS, Chrome, FireFox, Edge, etc + User User `json:"user" gorm:"ForeignKey:UUID;not null"` + Name string `json:"name" gorm:"not null"` + Type string `json:"type" gorm:"not null"` // Android, iOS, Chrome, FireFox, Edge, etc RefreshKey string `json:"-"` } @@ -40,6 +40,7 @@ type User struct { Username string `json:"username" gorm:"unique"` FirstName string `json:"first_name"` LastName string `json:"last_name"` + Role string `json:"role"` AuthType string `json:"auth_type" gorm:"default:Local;not null"` Password string `json:"-"` }