Restructure project layout

This commit is contained in:
eikendev 2021-01-16 16:56:49 +01:00
parent a49db216d5
commit 9a4a096526
No known key found for this signature in database
GPG key ID: A1BDB1B28C8EF694
32 changed files with 35 additions and 35 deletions

192
internal/api/application.go Normal file
View file

@ -0,0 +1,192 @@
package api
import (
"errors"
"log"
"net/http"
"github.com/pushbits/server/internal/authentication"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
// ApplicationHandler holds information for processing requests about applications.
type ApplicationHandler struct {
DB Database
DP Dispatcher
}
func (h *ApplicationHandler) applicationExists(token string) bool {
application, _ := h.DB.GetApplicationByToken(token)
return application != nil
}
func (h *ApplicationHandler) registerApplication(ctx *gin.Context, a *model.Application, u *model.User) error {
log.Printf("Registering application %s.\n", a.Name)
channelID, err := h.DP.RegisterApplication(a.ID, a.Name, a.Token, u.MatrixID)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
a.MatrixID = channelID
h.DB.UpdateApplication(a)
return nil
}
func (h *ApplicationHandler) createApplication(ctx *gin.Context, name string, u *model.User) (*model.Application, error) {
log.Printf("Creating application %s.\n", name)
application := model.Application{}
application.Name = name
application.Token = authentication.GenerateNotExistingToken(authentication.GenerateApplicationToken, h.applicationExists)
application.UserID = u.ID
err := h.DB.CreateApplication(&application)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return nil, err
}
if err := h.registerApplication(ctx, &application, u); err != nil {
err := h.DB.DeleteApplication(&application)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
log.Printf("Cannot delete application with ID %d.\n", application.ID)
}
return nil, err
}
return &application, nil
}
func (h *ApplicationHandler) deleteApplication(ctx *gin.Context, a *model.Application, u *model.User) error {
log.Printf("Deleting application %s (ID %d).\n", a.Name, a.ID)
err := h.DP.DeregisterApplication(a, u)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
err = h.DB.DeleteApplication(a)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
return nil
}
func (h *ApplicationHandler) updateApplication(ctx *gin.Context, a *model.Application, updateApplication *model.UpdateApplication) error {
log.Printf("Updating application %s.\n", a.Name)
if updateApplication.Name != nil {
a.Name = *updateApplication.Name
}
err := h.DB.UpdateApplication(a)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
return nil
}
// CreateApplication creates an application.
func (h *ApplicationHandler) CreateApplication(ctx *gin.Context) {
var createApplication model.CreateApplication
if err := ctx.Bind(&createApplication); err != nil {
return
}
user := authentication.GetUser(ctx)
if user == nil {
return
}
application, err := h.createApplication(ctx, createApplication.Name, user)
if err != nil {
return
}
ctx.JSON(http.StatusOK, &application)
}
// GetApplications returns all applications of the current user.
func (h *ApplicationHandler) GetApplications(ctx *gin.Context) {
user := authentication.GetUser(ctx)
if user == nil {
return
}
applications, err := h.DB.GetApplications(user)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return
}
ctx.JSON(http.StatusOK, &applications)
}
// GetApplication returns the application with the specified ID.
func (h *ApplicationHandler) GetApplication(ctx *gin.Context) {
application, err := getApplication(ctx, h.DB)
if err != nil {
return
}
user := authentication.GetUser(ctx)
if user == nil {
return
}
if user.ID != application.UserID {
err := errors.New("application belongs to another user")
ctx.AbortWithError(http.StatusForbidden, err)
return
}
ctx.JSON(http.StatusOK, &application)
}
// DeleteApplication deletes an application with a certain ID.
func (h *ApplicationHandler) DeleteApplication(ctx *gin.Context) {
application, err := getApplication(ctx, h.DB)
if err != nil {
return
}
if !isCurrentUser(ctx, application.UserID) {
return
}
if err := h.deleteApplication(ctx, application, authentication.GetUser(ctx)); err != nil {
return
}
ctx.JSON(http.StatusOK, gin.H{})
}
// UpdateApplication updates an application with a certain ID.
func (h *ApplicationHandler) UpdateApplication(ctx *gin.Context) {
application, err := getApplication(ctx, h.DB)
if err != nil {
return
}
if !isCurrentUser(ctx, application.UserID) {
return
}
var updateApplication model.UpdateApplication
if err := ctx.BindUri(&updateApplication); err != nil {
return
}
if err := h.updateApplication(ctx, application, &updateApplication); err != nil {
return
}
ctx.JSON(http.StatusOK, gin.H{})
}

49
internal/api/context.go Normal file
View file

@ -0,0 +1,49 @@
package api
import (
"errors"
"net/http"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
func getID(ctx *gin.Context) (uint, error) {
id, ok := ctx.MustGet("id").(uint)
if !ok {
err := errors.New("an error occured while retrieving ID from context")
ctx.AbortWithError(http.StatusInternalServerError, err)
return 0, err
}
return id, nil
}
func getApplication(ctx *gin.Context, db Database) (*model.Application, error) {
id, err := getID(ctx)
if err != nil {
return nil, err
}
application, err := db.GetApplicationByID(id)
if success := successOrAbort(ctx, http.StatusNotFound, err); !success {
return nil, err
}
return application, nil
}
func getUser(ctx *gin.Context, db Database) (*model.User, error) {
id, err := getID(ctx)
if err != nil {
return nil, err
}
user, err := db.GetUserByID(id)
if success := successOrAbort(ctx, http.StatusNotFound, err); !success {
return nil, err
}
return user, nil
}

22
internal/api/health.go Normal file
View file

@ -0,0 +1,22 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// HealthHandler holds information for processing requests about the server's health.
type HealthHandler struct {
DB Database
}
// Health returns the health status of the server.
func (h *HealthHandler) Health(ctx *gin.Context) {
if err := h.DB.Health(); err != nil {
ctx.AbortWithError(http.StatusInternalServerError, err)
return
}
ctx.JSON(http.StatusOK, gin.H{})
}

View file

@ -0,0 +1,36 @@
package api
import (
"github.com/pushbits/server/internal/model"
)
// The Database interface for encapsulating database access.
type Database interface {
Health() error
CreateApplication(application *model.Application) error
DeleteApplication(application *model.Application) error
GetApplicationByID(ID uint) (*model.Application, error)
GetApplicationByToken(token string) (*model.Application, error)
UpdateApplication(application *model.Application) error
AdminUserCount() (int64, error)
CreateUser(user model.CreateUser) (*model.User, error)
DeleteUser(user *model.User) error
GetApplications(user *model.User) ([]model.Application, error)
GetUserByID(ID uint) (*model.User, error)
GetUserByName(name string) (*model.User, error)
GetUsers() ([]model.User, error)
UpdateUser(user *model.User) error
}
// The Dispatcher interface for relaying notifications.
type Dispatcher interface {
RegisterApplication(id uint, name, token, user string) (string, error)
DeregisterApplication(a *model.Application, u *model.User) error
}
// The CredentialsManager interface for updating credentials.
type CredentialsManager interface {
CreatePasswordHash(password string) ([]byte, error)
}

View file

@ -0,0 +1,22 @@
package api
import (
"github.com/gin-gonic/gin"
)
type idInURI struct {
ID uint `uri:"id" binding:"required"`
}
// RequireIDInURI returns a Gin middleware which requires an ID to be supplied in the URI of the request.
func RequireIDInURI() gin.HandlerFunc {
return func(ctx *gin.Context) {
var requestModel idInURI
if err := ctx.BindUri(&requestModel); err != nil {
return
}
ctx.Set("id", requestModel.ID)
}
}

View file

@ -0,0 +1,53 @@
package api
import (
"log"
"net/http"
"strings"
"time"
"github.com/pushbits/server/internal/authentication"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
// The NotificationDatabase interface for encapsulating database access.
type NotificationDatabase interface {
}
// The NotificationDispatcher interface for relaying notifications.
type NotificationDispatcher interface {
SendNotification(a *model.Application, n *model.Notification) error
}
// NotificationHandler holds information for processing requests about notifications.
type NotificationHandler struct {
DB NotificationDatabase
DP NotificationDispatcher
}
// CreateNotification is used to create a new notification for a user.
func (h *NotificationHandler) CreateNotification(ctx *gin.Context) {
var notification model.Notification
if err := ctx.Bind(&notification); err != nil {
return
}
application := authentication.GetApplication(ctx)
log.Printf("Sending notification for application %s.\n", application.Name)
notification.ID = 0
notification.ApplicationID = application.ID
if strings.TrimSpace(notification.Title) == "" {
notification.Title = application.Name
}
notification.Date = time.Now()
if success := successOrAbort(ctx, http.StatusInternalServerError, h.DP.SendNotification(application, &notification)); !success {
return
}
ctx.JSON(http.StatusOK, &notification)
}

226
internal/api/user.go Normal file
View file

@ -0,0 +1,226 @@
package api
import (
"errors"
"log"
"net/http"
"github.com/pushbits/server/internal/authentication"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
// UserHandler holds information for processing requests about users.
type UserHandler struct {
AH *ApplicationHandler
CM CredentialsManager
DB Database
DP Dispatcher
}
func (h *UserHandler) userExists(name string) bool {
user, _ := h.DB.GetUserByName(name)
return user != nil
}
func (h *UserHandler) requireMultipleAdmins(ctx *gin.Context) error {
if count, err := h.DB.AdminUserCount(); err != nil {
ctx.AbortWithError(http.StatusInternalServerError, err)
return err
} else if count == 1 {
err := errors.New("instance needs at least one privileged user")
ctx.AbortWithError(http.StatusBadRequest, err)
return err
}
return nil
}
func (h *UserHandler) deleteApplications(ctx *gin.Context, u *model.User) error {
applications, err := h.DB.GetApplications(u)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
for _, application := range applications {
if err := h.AH.deleteApplication(ctx, &application, u); err != nil {
return err
}
}
return nil
}
func (h *UserHandler) updateChannels(ctx *gin.Context, u *model.User, matrixID string) error {
applications, err := h.DB.GetApplications(u)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
for _, application := range applications {
err := h.DP.DeregisterApplication(&application, u)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
}
u.MatrixID = matrixID
for _, application := range applications {
err := h.AH.registerApplication(ctx, &application, u)
if err != nil {
return err
}
}
return nil
}
func (h *UserHandler) updateUser(ctx *gin.Context, u *model.User, updateUser model.UpdateUser) error {
if updateUser.MatrixID != nil && u.MatrixID != *updateUser.MatrixID {
if err := h.updateChannels(ctx, u, *updateUser.MatrixID); err != nil {
return err
}
}
log.Printf("Updating user %s.\n", u.Name)
if updateUser.Name != nil {
u.Name = *updateUser.Name
}
if updateUser.Password != nil {
hash, err := h.CM.CreatePasswordHash(*updateUser.Password)
if success := successOrAbort(ctx, http.StatusBadRequest, err); !success {
return err
}
u.PasswordHash = hash
}
if updateUser.MatrixID != nil {
u.MatrixID = *updateUser.MatrixID
}
if updateUser.IsAdmin != nil {
u.IsAdmin = *updateUser.IsAdmin
}
err := h.DB.UpdateUser(u)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return err
}
return nil
}
// CreateUser creates a new user.
// This method assumes that the requesting user has privileges.
func (h *UserHandler) CreateUser(ctx *gin.Context) {
var createUser model.CreateUser
if err := ctx.Bind(&createUser); err != nil {
return
}
if h.userExists(createUser.Name) {
ctx.AbortWithError(http.StatusBadRequest, errors.New("username already exists"))
return
}
log.Printf("Creating user %s.\n", createUser.Name)
user, err := h.DB.CreateUser(createUser)
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return
}
ctx.JSON(http.StatusOK, user.IntoExternalUser())
}
// GetUsers returns all users.
// This method assumes that the requesting user has privileges.
func (h *UserHandler) GetUsers(ctx *gin.Context) {
users, err := h.DB.GetUsers()
if success := successOrAbort(ctx, http.StatusInternalServerError, err); !success {
return
}
var externalUsers []*model.ExternalUser
for _, user := range users {
externalUsers = append(externalUsers, user.IntoExternalUser())
}
ctx.JSON(http.StatusOK, &externalUsers)
}
// GetUser returns the user with the specified ID.
// This method assumes that the requesting user has privileges.
func (h *UserHandler) GetUser(ctx *gin.Context) {
user, err := getUser(ctx, h.DB)
if err != nil {
return
}
ctx.JSON(http.StatusOK, user.IntoExternalUser())
}
// DeleteUser deletes a user with a certain ID.
//
// This method assumes that the requesting user has privileges.
func (h *UserHandler) DeleteUser(ctx *gin.Context) {
user, err := getUser(ctx, h.DB)
if err != nil {
return
}
// Last privileged user must not be deleted.
if user.IsAdmin {
if err := h.requireMultipleAdmins(ctx); err != nil {
return
}
}
log.Printf("Deleting user %s.\n", user.Name)
if err := h.deleteApplications(ctx, user); err != nil {
return
}
if success := successOrAbort(ctx, http.StatusInternalServerError, h.DB.DeleteUser(user)); !success {
return
}
ctx.JSON(http.StatusOK, gin.H{})
}
// UpdateUser updates a user with a certain ID.
//
// This method assumes that the requesting user has privileges. If users can later update their own user, make sure they
// cannot give themselves privileges.
func (h *UserHandler) UpdateUser(ctx *gin.Context) {
user, err := getUser(ctx, h.DB)
if err != nil {
return
}
var updateUser model.UpdateUser
if err := ctx.BindUri(&updateUser); err != nil {
return
}
requestingUser := authentication.GetUser(ctx)
// Last privileged user must not be taken privileges. Assumes that the current user has privileges.
if user.ID == requestingUser.ID && updateUser.IsAdmin != nil && !(*updateUser.IsAdmin) {
if err := h.requireMultipleAdmins(ctx); err != nil {
return
}
}
if err := h.updateUser(ctx, user, updateUser); err != nil {
return
}
ctx.JSON(http.StatusOK, gin.H{})
}

29
internal/api/util.go Normal file
View file

@ -0,0 +1,29 @@
package api
import (
"errors"
"net/http"
"github.com/pushbits/server/internal/authentication"
"github.com/gin-gonic/gin"
)
func successOrAbort(ctx *gin.Context, code int, err error) bool {
if err != nil {
ctx.AbortWithError(code, err)
}
return err == nil
}
func isCurrentUser(ctx *gin.Context, ID uint) bool {
user := authentication.GetUser(ctx)
if user.ID != ID {
ctx.AbortWithError(http.StatusForbidden, errors.New("only owner can delete application"))
return false
}
return true
}

12
internal/assert/assert.go Normal file
View file

@ -0,0 +1,12 @@
package assert
import (
"errors"
)
// Assert panics if condition is false.
func Assert(condition bool) {
if !condition {
panic(errors.New("assertion failed"))
}
}

View file

@ -0,0 +1,106 @@
package authentication
import (
"errors"
"net/http"
"github.com/pushbits/server/internal/authentication/credentials"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
const (
headerName = "X-Gotify-Key"
)
// The Database interface for encapsulating database access.
type Database interface {
GetApplicationByToken(token string) (*model.Application, error)
GetUserByName(name string) (*model.User, error)
}
// Authenticator is the provider for authentication middleware.
type Authenticator struct {
DB Database
}
type hasUserProperty func(user *model.User) bool
func (a *Authenticator) userFromBasicAuth(ctx *gin.Context) (*model.User, error) {
if name, password, ok := ctx.Request.BasicAuth(); ok {
if user, err := a.DB.GetUserByName(name); err != nil {
return nil, err
} else if user != nil && credentials.ComparePassword(user.PasswordHash, []byte(password)) {
return user, nil
} else {
return nil, errors.New("credentials were invalid")
}
}
return nil, errors.New("no credentials were supplied")
}
func (a *Authenticator) requireUserProperty(has hasUserProperty) gin.HandlerFunc {
return func(ctx *gin.Context) {
user, err := a.userFromBasicAuth(ctx)
if err != nil {
ctx.AbortWithError(http.StatusForbidden, err)
return
}
if !has(user) {
ctx.AbortWithError(http.StatusForbidden, errors.New("authentication failed"))
return
}
ctx.Set("user", user)
}
}
// RequireUser returns a Gin middleware which requires valid user credentials to be supplied with the request.
func (a *Authenticator) RequireUser() gin.HandlerFunc {
return a.requireUserProperty(func(user *model.User) bool {
return true
})
}
// RequireAdmin returns a Gin middleware which requires valid admin credentials to be supplied with the request.
func (a *Authenticator) RequireAdmin() gin.HandlerFunc {
return a.requireUserProperty(func(user *model.User) bool {
return user.IsAdmin
})
}
func (a *Authenticator) tokenFromQueryOrHeader(ctx *gin.Context) string {
if token := a.tokenFromQuery(ctx); token != "" {
return token
} else if token := a.tokenFromHeader(ctx); token != "" {
return token
}
return ""
}
func (a *Authenticator) tokenFromQuery(ctx *gin.Context) string {
return ctx.Request.URL.Query().Get("token")
}
func (a *Authenticator) tokenFromHeader(ctx *gin.Context) string {
return ctx.Request.Header.Get(headerName)
}
// RequireApplicationToken returns a Gin middleware which requires an application token to be supplied with the request.
func (a *Authenticator) RequireApplicationToken() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := a.tokenFromQueryOrHeader(ctx)
app, err := a.DB.GetApplicationByToken(token)
if err != nil {
ctx.AbortWithError(http.StatusForbidden, err)
return
}
ctx.Set("app", app)
}
}

View file

@ -0,0 +1,32 @@
package authentication
import (
"errors"
"net/http"
"github.com/pushbits/server/internal/model"
"github.com/gin-gonic/gin"
)
// GetApplication returns the application which was previously registered by the authentication middleware.
func GetApplication(ctx *gin.Context) *model.Application {
app, ok := ctx.MustGet("app").(*model.Application)
if app == nil || !ok {
ctx.AbortWithError(http.StatusInternalServerError, errors.New("an error occured while retrieving application from context"))
return nil
}
return app
}
// GetUser returns the user which was previously registered by the authentication middleware.
func GetUser(ctx *gin.Context) *model.User {
user, ok := ctx.MustGet("user").(*model.User)
if user == nil || !ok {
ctx.AbortWithError(http.StatusInternalServerError, errors.New("an error occured while retrieving user from context"))
return nil
}
return user
}

View file

@ -0,0 +1,33 @@
package credentials
import (
"log"
"github.com/pushbits/server/internal/configuration"
"github.com/alexedwards/argon2id"
)
// Manager holds information for managing credentials.
type Manager struct {
checkHIBP bool
argon2Params *argon2id.Params
}
// CreateManager instanciates a credential manager.
func CreateManager(checkHIBP bool, c configuration.CryptoConfig) *Manager {
log.Println("Setting up credential manager.")
argon2Params := &argon2id.Params{
Memory: c.Argon2.Memory,
Iterations: c.Argon2.Iterations,
Parallelism: c.Argon2.Parallelism,
SaltLength: c.Argon2.SaltLength,
KeyLength: c.Argon2.KeyLength,
}
return &Manager{
checkHIBP: checkHIBP,
argon2Params: argon2Params,
}
}

View file

@ -0,0 +1,61 @@
package credentials
import (
"crypto/sha1"
"fmt"
"io/ioutil"
"log"
"net/http"
"strings"
)
const (
base = "https://api.pwnedpasswords.com"
pwnedHashesEndpoint = "/range"
pwnedHashesURL = base + pwnedHashesEndpoint + "/"
)
// IsPasswordPwned determines whether or not the password is weak.
func IsPasswordPwned(password string) (bool, error) {
if len(password) == 0 {
return true, nil
}
hash := sha1.Sum([]byte(password))
hashStr := fmt.Sprintf("%X", hash)
lookup := hashStr[0:5]
match := hashStr[5:]
log.Printf("Checking HIBP for hashes starting with '%s'.\n", lookup)
resp, err := http.Get(pwnedHashesURL + lookup)
if err != nil {
return false, err
}
if resp.StatusCode != http.StatusOK {
log.Fatalf("Request failed with HTTP %s.", resp.Status)
}
defer resp.Body.Close()
bodyText, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
bodyStr := string(bodyText)
lines := strings.Split(bodyStr, "\n")
for _, line := range lines {
separated := strings.Split(line, ":")
if len(separated) != 2 {
return false, fmt.Errorf("HIPB API returned malformed response: %s", line)
}
if separated[0] == match {
return true, nil
}
}
return false, nil
}

View file

@ -0,0 +1,23 @@
package credentials
import "testing"
type isPasswordPwnedTest struct {
arg string
exp1 bool
exp2 error
}
var isPasswordPwnedTests = []isPasswordPwnedTest{
{"", true, nil},
{"password", true, nil},
{"2y6bWMETuHpNP08HCZq00QAAzE6nmwEb", false, nil},
}
func TestIsPasswordPwned(t *testing.T) {
for _, test := range isPasswordPwnedTests {
if out1, out2 := IsPasswordPwned(test.arg); out1 != test.exp1 || out2 != test.exp2 {
t.Errorf("Output (%t,%q) not equal to expected (%t,%q)", out1, out2, test.exp1, test.exp2)
}
}
}

View file

@ -0,0 +1,41 @@
package credentials
import (
"errors"
"log"
"github.com/alexedwards/argon2id"
)
// CreatePasswordHash returns a hashed version of the given password.
func (m *Manager) CreatePasswordHash(password string) ([]byte, error) {
if m.checkHIBP {
pwned, err := IsPasswordPwned(password)
if err != nil {
return []byte{}, errors.New("HIBP is not available, please wait until service is available again")
} else if pwned {
return []byte{}, errors.New("Password is pwned, please choose another one")
}
}
hash, err := argon2id.CreateHash(password, m.argon2Params)
if err != nil {
log.Fatal(err)
panic(err)
}
return []byte(hash), nil
}
// ComparePassword compares a hashed password with its possible plaintext equivalent.
func ComparePassword(hash, password []byte) bool {
match, err := argon2id.ComparePasswordAndHash(string(password), string(hash))
if err != nil {
log.Fatal(err)
return false
}
return match
}

View file

@ -0,0 +1,54 @@
package authentication
import (
"crypto/rand"
"math/big"
)
var (
tokenCharacters = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
randomTokenLength = 64
applicationPrefix = "A"
)
func randIntn(n int) int {
max := big.NewInt(int64(n))
res, err := rand.Int(rand.Reader, max)
if err != nil {
panic("random source is not available")
}
return int(res.Int64())
}
// GenerateNotExistingToken receives a token generation function and a function to check whether the token exists, returns a unique token.
func GenerateNotExistingToken(generateToken func() string, tokenExists func(token string) bool) string {
for {
token := generateToken()
if !tokenExists(token) {
return token
}
}
}
func generateRandomString(length int) string {
res := make([]byte, length)
for i := range res {
index := randIntn(len(tokenCharacters))
res[i] = tokenCharacters[index]
}
return string(res)
}
func generateRandomToken(prefix string) string {
return prefix + generateRandomString(randomTokenLength)
}
// GenerateApplicationToken generates a token for an application.
func GenerateApplicationToken() string {
return generateRandomToken(applicationPrefix)
}

View file

@ -0,0 +1,66 @@
package configuration
import (
"github.com/jinzhu/configor"
)
// Argon2Config holds the parameters used for creating hashes with Argon2.
type Argon2Config struct {
Memory uint32 `default:"131072"`
Iterations uint32 `default:"4"`
Parallelism uint8 `default:"4"`
SaltLength uint32 `default:"16"`
KeyLength uint32 `default:"32"`
}
// CryptoConfig holds the parameters used for creating hashes.
type CryptoConfig struct {
Argon2 Argon2Config
}
// Configuration holds values that can be configured by the user.
type Configuration struct {
Debug bool `default:"false"`
HTTP struct {
ListenAddress string `default:""`
Port int `default:"8080"`
}
Database struct {
Dialect string `default:"sqlite3"`
Connection string `default:"pushbits.db"`
}
Admin struct {
Name string `default:"admin"`
Password string `default:"admin"`
MatrixID string `required:"true"`
}
Matrix struct {
Homeserver string `default:"https://matrix.org"`
Username string `required:"true"`
Password string `required:"true"`
}
Security struct {
CheckHIBP bool `default:"false"`
}
Crypto CryptoConfig
}
func configFiles() []string {
return []string{"config.yml"}
}
// Get returns the configuration extracted from env variables or config file.
func Get() *Configuration {
config := &Configuration{}
err := configor.New(&configor.Config{
Environment: "production",
ENVPrefix: "PUSHBITS",
ErrorOnUnmatchedKeys: true,
}).Load(config, configFiles()...)
if err != nil {
panic(err)
}
return config
}

View file

@ -0,0 +1,55 @@
package database
import (
"errors"
"github.com/pushbits/server/internal/assert"
"github.com/pushbits/server/internal/model"
"gorm.io/gorm"
)
// CreateApplication creates an application.
func (d *Database) CreateApplication(application *model.Application) error {
return d.gormdb.Create(application).Error
}
// DeleteApplication deletes an application.
func (d *Database) DeleteApplication(application *model.Application) error {
return d.gormdb.Delete(application).Error
}
// UpdateApplication updates an application.
func (d *Database) UpdateApplication(application *model.Application) error {
return d.gormdb.Save(application).Error
}
// GetApplicationByID returns the application with the given ID or nil.
func (d *Database) GetApplicationByID(ID uint) (*model.Application, error) {
var application model.Application
err := d.gormdb.First(&application, ID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
assert.Assert(application.ID == ID)
return &application, err
}
// GetApplicationByToken returns the application with the given token or nil.
func (d *Database) GetApplicationByToken(token string) (*model.Application, error) {
var application model.Application
err := d.gormdb.Where("token = ?", token).First(&application).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
assert.Assert(application.Token == token)
return &application, err
}

View file

@ -0,0 +1,100 @@
package database
import (
"database/sql"
"errors"
"log"
"os"
"path/filepath"
"time"
"github.com/pushbits/server/internal/authentication/credentials"
"github.com/pushbits/server/internal/model"
"gorm.io/driver/mysql"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Database holds information for the database connection.
type Database struct {
gormdb *gorm.DB
sqldb *sql.DB
credentialsManager *credentials.Manager
}
func createFileDir(file string) {
if _, err := os.Stat(filepath.Dir(file)); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(file), 0775); err != nil {
panic(err)
}
}
}
// Create instanciates a database connection.
func Create(cm *credentials.Manager, dialect, connection string) (*Database, error) {
log.Println("Setting up database connection.")
maxOpenConns := 5
var db *gorm.DB
var err error
switch dialect {
case "sqlite3":
createFileDir(connection)
maxOpenConns = 1
db, err = gorm.Open(sqlite.Open(connection), &gorm.Config{})
case "mysql":
db, err = gorm.Open(mysql.Open(connection), &gorm.Config{})
default:
message := "Database dialect is not supported"
return nil, errors.New(message)
}
if err != nil {
return nil, err
}
sql, err := db.DB()
if err != nil {
return nil, err
}
sql.SetMaxOpenConns(maxOpenConns)
if dialect == "mysql" {
sql.SetConnMaxLifetime(9 * time.Minute)
}
db.AutoMigrate(&model.User{}, &model.Application{})
return &Database{gormdb: db, sqldb: sql, credentialsManager: cm}, nil
}
// Close closes the database connection.
func (d *Database) Close() {
d.sqldb.Close()
}
// Populate fills the database with initial information like the admin user.
func (d *Database) Populate(name, password, matrixID string) error {
var user model.User
query := d.gormdb.Where("name = ?", name).First(&user)
if errors.Is(query.Error, gorm.ErrRecordNotFound) {
user, err := model.NewUser(d.credentialsManager, name, password, true, matrixID)
if err != nil {
log.Fatal(err)
}
if err := d.gormdb.Create(&user).Error; err != nil {
return errors.New("user cannot be created")
}
} else {
log.Printf("Admin user %s already exists.\n", name)
}
return nil
}

View file

@ -0,0 +1,6 @@
package database
// Health reports the status of the database connection.
func (d *Database) Health() error {
return d.sqldb.Ping()
}

91
internal/database/user.go Normal file
View file

@ -0,0 +1,91 @@
package database
import (
"errors"
"github.com/pushbits/server/internal/assert"
"github.com/pushbits/server/internal/model"
"gorm.io/gorm"
)
// CreateUser creates a user.
func (d *Database) CreateUser(createUser model.CreateUser) (*model.User, error) {
user, err := createUser.IntoInternalUser(d.credentialsManager)
if err != nil {
return nil, err
}
return user, d.gormdb.Create(user).Error
}
// DeleteUser deletes a user.
func (d *Database) DeleteUser(user *model.User) error {
if err := d.gormdb.Where("user_id = ?", user.ID).Delete(model.Application{}).Error; err != nil {
return err
}
return d.gormdb.Delete(user).Error
}
// UpdateUser updates a user.
func (d *Database) UpdateUser(user *model.User) error {
return d.gormdb.Save(user).Error
}
// GetUserByID returns the user with the given ID or nil.
func (d *Database) GetUserByID(ID uint) (*model.User, error) {
var user model.User
err := d.gormdb.First(&user, ID).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
assert.Assert(user.ID == ID)
return &user, err
}
// GetUserByName returns the user with the given name or nil.
func (d *Database) GetUserByName(name string) (*model.User, error) {
var user model.User
err := d.gormdb.Where("name = ?", name).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
assert.Assert(user.Name == name)
return &user, err
}
// GetApplications returns the applications associated with a given user.
func (d *Database) GetApplications(user *model.User) ([]model.Application, error) {
var applications []model.Application
err := d.gormdb.Model(user).Association("Applications").Find(&applications)
return applications, err
}
// GetUsers returns all users.
func (d *Database) GetUsers() ([]model.User, error) {
var users []model.User
err := d.gormdb.Find(&users).Error
return users, err
}
// AdminUserCount returns the number of admins or an error.
func (d *Database) AdminUserCount() (int64, error) {
var users []model.User
query := d.gormdb.Where("is_admin = ?", true).Find(&users)
return query.RowsAffected, query.Error
}

View file

@ -0,0 +1,62 @@
package dispatcher
import (
"fmt"
"log"
"github.com/pushbits/server/internal/model"
"github.com/matrix-org/gomatrix"
)
// RegisterApplication creates a channel for an application.
func (d *Dispatcher) RegisterApplication(id uint, name, token, user string) (string, error) {
log.Printf("Registering application %s, notifications will be relayed to user %s.\n", name, user)
topic := fmt.Sprintf("Application %d, Token %s", id, token)
response, err := d.client.CreateRoom(&gomatrix.ReqCreateRoom{
Invite: []string{user},
IsDirect: true,
Name: name,
Preset: "private_chat",
Topic: topic,
Visibility: "private",
})
if err != nil {
log.Fatal(err)
return "", err
}
log.Printf("Application %s is now relayed to room with ID %s.\n", name, response.RoomID)
return response.RoomID, err
}
// DeregisterApplication deletes a channel for an application.
func (d *Dispatcher) DeregisterApplication(a *model.Application, u *model.User) error {
log.Printf("Deregistering application %s (ID %d) with Matrix ID %s.\n", a.Name, a.ID, a.MatrixID)
kickUser := &gomatrix.ReqKickUser{
Reason: "This application was deleted",
UserID: u.MatrixID,
}
if _, err := d.client.KickUser(a.MatrixID, kickUser); err != nil {
log.Fatal(err)
return err
}
if _, err := d.client.LeaveRoom(a.MatrixID); err != nil {
log.Fatal(err)
return err
}
if _, err := d.client.ForgetRoom(a.MatrixID); err != nil {
log.Fatal(err)
return err
}
return nil
}

View file

@ -0,0 +1,52 @@
package dispatcher
import (
"log"
"github.com/matrix-org/gomatrix"
)
var (
loginType = "m.login.password"
)
// The Database interface for encapsulating database access.
type Database interface {
}
// Dispatcher holds information for sending notifications to clients.
type Dispatcher struct {
db Database
client *gomatrix.Client
}
// Create instanciates a dispatcher connection.
func Create(db Database, homeserver, username, password string) (*Dispatcher, error) {
log.Println("Setting up dispatcher.")
client, err := gomatrix.NewClient(homeserver, "", "")
if err != nil {
return nil, err
}
response, err := client.Login(&gomatrix.ReqLogin{
Type: loginType,
User: username,
Password: password,
})
if err != nil {
return nil, err
}
client.SetCredentials(response.UserID, response.AccessToken)
return &Dispatcher{client: client}, nil
}
// Close closes the dispatcher connection.
func (d *Dispatcher) Close() {
log.Printf("Logging out.\n")
d.client.Logout()
d.client.ClearCredentials()
}

View file

@ -0,0 +1,19 @@
package dispatcher
import (
"fmt"
"log"
"github.com/pushbits/server/internal/model"
)
// SendNotification sends a notification to the specified user.
func (d *Dispatcher) SendNotification(a *model.Application, n *model.Notification) error {
log.Printf("Sending notification to room %s.\n", a.MatrixID)
text := fmt.Sprintf("%s\n\n%s", n.Title, n.Message)
_, err := d.client.SendText(a.MatrixID, text)
return err
}

View file

@ -0,0 +1,20 @@
package model
// Application holds information like the name, the token, and the associated user of an application.
type Application struct {
ID uint `gorm:"AUTO_INCREMENT;primary_key" json:"id"`
Token string `gorm:"type:string;size:64;unique" json:"token"`
UserID uint `json:"-"`
Name string `gorm:"type:string" json:"name"`
MatrixID string `gorm:"type:string" json:"-"`
}
// CreateApplication is used to process queries for creating applications.
type CreateApplication struct {
Name string `form:"name" query:"name" json:"name" binding:"required"`
}
// UpdateApplication is used to process queries for updating applications.
type UpdateApplication struct {
Name *string `json:"name"`
}

View file

@ -0,0 +1,16 @@
package model
import (
"time"
)
// Notification holds information like the message, the title, and the priority of a notification.
type Notification struct {
ID uint `json:"id"`
ApplicationID uint `json:"appid"`
Message string `json:"message" form:"message" query:"message" binding:"required"`
Title string `json:"title" form:"title" query:"title"`
Priority int `json:"priority" form:"priority" query:"priority"`
Extras map[string]interface{} `json:"extras,omitempty" form:"-" query:"-"`
Date time.Time `json:"date"`
}

86
internal/model/user.go Normal file
View file

@ -0,0 +1,86 @@
package model
import (
"log"
"github.com/pushbits/server/internal/authentication/credentials"
)
// User holds information like the name, the secret, and the applications of a user.
type User struct {
ID uint `gorm:"AUTO_INCREMENT;primary_key"`
Name string `gorm:"type:string;size:128;unique"`
PasswordHash []byte
IsAdmin bool
MatrixID string `gorm:"type:string"`
Applications []Application
}
// ExternalUser represents a user for external purposes.
type ExternalUser struct {
ID uint `json:"id"`
Name string `json:"name" form:"name" query:"name" binding:"required"`
IsAdmin bool `json:"is_admin" form:"is_admin" query:"is_admin"`
MatrixID string `json:"matrix_id" form:"matrix_id" query:"matrix_id" binding:"required"`
}
// UserCredentials holds information for authenticating a user.
type UserCredentials struct {
Password string `json:"password,omitempty" form:"password" query:"password" binding:"required"`
}
// CreateUser is used to process queries for creating users.
type CreateUser struct {
ExternalUser
UserCredentials
}
// NewUser creates a new user.
func NewUser(cm *credentials.Manager, name, password string, isAdmin bool, matrixID string) (*User, error) {
log.Printf("Creating user %s.\n", name)
passwordHash, err := cm.CreatePasswordHash(password)
if err != nil {
return nil, err
}
return &User{
Name: name,
PasswordHash: passwordHash,
IsAdmin: isAdmin,
MatrixID: matrixID,
}, nil
}
// IntoInternalUser converts a CreateUser into a User.
func (u *CreateUser) IntoInternalUser(cm *credentials.Manager) (*User, error) {
passwordHash, err := cm.CreatePasswordHash(u.Password)
if err != nil {
return nil, err
}
return &User{
Name: u.Name,
PasswordHash: passwordHash,
IsAdmin: u.IsAdmin,
MatrixID: u.MatrixID,
}, nil
}
// IntoExternalUser converts a User into a ExternalUser.
func (u *User) IntoExternalUser() *ExternalUser {
return &ExternalUser{
ID: u.ID,
Name: u.Name,
IsAdmin: u.IsAdmin,
MatrixID: u.MatrixID,
}
}
// UpdateUser is used to process queries for updating users.
type UpdateUser struct {
Name *string `json:"name"`
Password *string `json:"password"`
IsAdmin *bool `json:"is_admin"`
MatrixID *string `json:"matrix_id"`
}

62
internal/router/router.go Normal file
View file

@ -0,0 +1,62 @@
package router
import (
"log"
"github.com/pushbits/server/internal/api"
"github.com/pushbits/server/internal/authentication"
"github.com/pushbits/server/internal/authentication/credentials"
"github.com/pushbits/server/internal/database"
"github.com/pushbits/server/internal/dispatcher"
"github.com/gin-contrib/location"
"github.com/gin-gonic/gin"
)
// Create a Gin engine and setup all routes.
func Create(debug bool, cm *credentials.Manager, db *database.Database, dp *dispatcher.Dispatcher) *gin.Engine {
log.Println("Setting up HTTP routes.")
if !debug {
gin.SetMode(gin.ReleaseMode)
}
auth := authentication.Authenticator{DB: db}
applicationHandler := api.ApplicationHandler{DB: db, DP: dp}
healthHandler := api.HealthHandler{DB: db}
notificationHandler := api.NotificationHandler{DB: db, DP: dp}
userHandler := api.UserHandler{AH: &applicationHandler, CM: cm, DB: db, DP: dp}
r := gin.Default()
r.Use(location.Default())
applicationGroup := r.Group("/application")
applicationGroup.Use(auth.RequireUser())
{
applicationGroup.POST("", applicationHandler.CreateApplication)
applicationGroup.GET("", applicationHandler.GetApplications)
applicationGroup.GET("/:id", api.RequireIDInURI(), applicationHandler.GetApplication)
applicationGroup.DELETE("/:id", api.RequireIDInURI(), applicationHandler.DeleteApplication)
applicationGroup.PUT("/:id", api.RequireIDInURI(), applicationHandler.UpdateApplication)
}
r.GET("/health", healthHandler.Health)
r.POST("/message", auth.RequireApplicationToken(), notificationHandler.CreateNotification)
userGroup := r.Group("/user")
userGroup.Use(auth.RequireAdmin())
{
userGroup.POST("", userHandler.CreateUser)
userGroup.GET("", userHandler.GetUsers)
userGroup.GET("/:id", api.RequireIDInURI(), userHandler.GetUser)
userGroup.DELETE("/:id", api.RequireIDInURI(), userHandler.DeleteUser)
userGroup.PUT("/:id", api.RequireIDInURI(), userHandler.UpdateUser)
}
return r
}

12
internal/runner/runner.go Normal file
View file

@ -0,0 +1,12 @@
package runner
import (
"fmt"
"github.com/gin-gonic/gin"
)
// Run starts the Gin engine.
func Run(engine *gin.Engine, address string, port int) {
engine.Run(fmt.Sprintf("%s:%d", address, port))
}