Add registration

Can be enabled via the registration config flag. (disabled per default)

Fixes gotify/server#395

Co-authored-by: pigpig <pigpig@pig.pig>
Co-authored-by: Karmanyaah Malhotra <32671690+karmanyaahm@users.noreply.github.com>
Co-authored-by: Jannis Mattheis <contact@jmattheis.de>
This commit is contained in:
pigpig
2021-02-22 00:14:40 +01:00
committed by Jannis Mattheis
parent 7e261be304
commit c172590b92
16 changed files with 391 additions and 20 deletions

View File

@@ -535,6 +535,6 @@ func fakeImage(t *testing.T, path string) {
data, err := ioutil.ReadFile("../test/assets/image.png")
assert.Nil(t, err)
// Write data to dst
err = ioutil.WriteFile(path, data, 0644)
err = ioutil.WriteFile(path, data, 0o644)
assert.Nil(t, err)
}

View File

@@ -2,6 +2,8 @@ package api
import (
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
@@ -59,6 +61,7 @@ type UserAPI struct {
DB UserDatabase
PasswordStrength int
UserChangeNotifier *UserChangeNotifier
Registration bool
}
// GetUsers returns all the users
@@ -126,11 +129,14 @@ func (a *UserAPI) GetCurrentUser(ctx *gin.Context) {
ctx.JSON(200, toExternalUser(user))
}
// CreateUser creates a user
// CreateUser create a user.
// swagger:operation POST /user user createUser
//
// Create a user.
//
// With enabled registration: non admin users can be created without authentication.
// With disabled registrations: users can only be created by admin users.
//
// ---
// consumes: [application/json]
// produces: [application/json]
@@ -167,6 +173,32 @@ func (a *UserAPI) CreateUser(ctx *gin.Context) {
if success := successOrAbort(ctx, 500, err); !success {
return
}
var requestedBy *model.User
uid := auth.TryGetUserID(ctx)
if uid != nil {
requestedBy, err = a.DB.GetUserByID(*uid)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("could not get user: %s", err))
return
}
}
if requestedBy == nil || !requestedBy.Admin {
status := http.StatusUnauthorized
if requestedBy != nil {
status = http.StatusForbidden
}
if !a.Registration {
ctx.AbortWithError(status, errors.New("you are not allowed to access this api"))
return
}
if internal.Admin {
ctx.AbortWithError(status, errors.New("you are not allowed to create an admin user"))
return
}
}
if existingUser == nil {
if success := successOrAbort(ctx, 500, a.DB.CreateUser(internal)); !success {
return

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/gotify/server/v2/auth"
"github.com/gotify/server/v2/auth/password"
"github.com/gotify/server/v2/mode"
"github.com/gotify/server/v2/model"
@@ -35,7 +36,9 @@ func (s *UserSuite) BeforeTest(suiteName, testName string) {
mode.Set(mode.TestDev)
s.recorder = httptest.NewRecorder()
s.ctx, _ = gin.CreateTestContext(s.recorder)
s.db = testdb.NewDB(s.T())
s.notifier = new(UserChangeNotifier)
s.notifier.OnUserDeleted(func(uint) error {
s.notifiedDelete = true
@@ -164,15 +167,17 @@ func (s *UserSuite) Test_DeleteUserByID_NotifyFail() {
}
func (s *UserSuite) Test_CreateUser() {
s.loginAdmin()
assert.False(s.T(), s.notifiedAdd)
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
user := &model.UserExternal{ID: 1, Name: "tom", Admin: true}
test.BodyEquals(s.T(), user, s.recorder)
assert.Equal(s.T(), 200, s.recorder.Code)
user := &model.UserExternal{ID: 2, Name: "tom", Admin: true}
test.BodyEquals(s.T(), user, s.recorder)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
@@ -181,7 +186,88 @@ func (s *UserSuite) Test_CreateUser() {
assert.True(s.T(), s.notifiedAdd)
}
func (s *UserSuite) Test_CreateUser_ByNonAdmin() {
s.loginUser()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
}
func (s *UserSuite) Test_CreateUser_Register_ByNonAdmin() {
s.loginUser()
s.a.Registration = true
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
}
}
func (s *UserSuite) Test_CreateUser_Register_Admin_ByNonAdmin() {
s.a.Registration = true
s.loginUser()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 403, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_Anonymous() {
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 401, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_Register_Anonymous() {
s.a.Registration = true
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": false}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 200, s.recorder.Code)
if created, err := s.db.GetUserByName("tom"); assert.NoError(s.T(), err) {
assert.NotNil(s.T(), created)
}
}
func (s *UserSuite) Test_CreateUser_Register_Admin_Anonymous() {
s.a.Registration = true
s.noLogin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "1", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
s.a.CreateUser(s.ctx)
assert.Equal(s.T(), 401, s.recorder.Code)
s.db.AssertUsernameNotExist("tom")
}
func (s *UserSuite) Test_CreateUser_NotifyFail() {
s.loginAdmin()
s.notifier.OnUserAdded(func(id uint) error {
user, err := s.db.GetUserByID(id)
if err != nil {
@@ -201,6 +287,8 @@ func (s *UserSuite) Test_CreateUser_NotifyFail() {
}
func (s *UserSuite) Test_CreateUser_NoPassword() {
s.loginAdmin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
@@ -210,6 +298,8 @@ func (s *UserSuite) Test_CreateUser_NoPassword() {
}
func (s *UserSuite) Test_CreateUser_NoName() {
s.loginAdmin()
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "", "pass": "asd", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
@@ -219,7 +309,8 @@ func (s *UserSuite) Test_CreateUser_NoName() {
}
func (s *UserSuite) Test_CreateUser_NameAlreadyExists() {
s.db.NewUserWithName(1, "tom")
s.loginAdmin()
s.db.NewUserWithName(2, "tom")
s.ctx.Request = httptest.NewRequest("POST", "/user", strings.NewReader(`{"name": "tom", "pass": "mylittlepony", "admin": true}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
@@ -333,6 +424,20 @@ func (s *UserSuite) Test_UpdatePassword_EmptyPassword() {
assert.True(s.T(), password.ComparePassword(user.Pass, []byte("old")))
}
func (s *UserSuite) loginAdmin() {
s.db.CreateUser(&model.User{ID: 1, Name: "admin", Admin: true})
auth.RegisterAuthentication(s.ctx, nil, 1, "")
}
func (s *UserSuite) loginUser() {
s.db.CreateUser(&model.User{ID: 1, Name: "user", Admin: false})
auth.RegisterAuthentication(s.ctx, nil, 1, "")
}
func (s *UserSuite) noLogin() {
auth.RegisterAuthentication(s.ctx, nil, 0, "")
}
func externalOf(user *model.User) *model.UserExternal {
return &model.UserExternal{Name: user.Name, Admin: user.Admin, ID: user.ID}
}

View File

@@ -133,3 +133,29 @@ func (a *Auth) requireToken(auth authenticate) gin.HandlerFunc {
ctx.AbortWithError(401, errors.New("you need to provide a valid access token or user credentials to access this api"))
}
}
func (a *Auth) Optional() gin.HandlerFunc {
return func(ctx *gin.Context) {
token := a.tokenFromQueryOrHeader(ctx)
user, err := a.userFromBasicAuth(ctx)
if err != nil {
RegisterAuthentication(ctx, nil, 0, "")
ctx.Next()
return
}
if user != nil {
RegisterAuthentication(ctx, user, user.ID, token)
ctx.Next()
return
} else if token != "" {
if tokenClient, err := a.DB.GetClientByToken(token); err == nil && tokenClient != nil {
RegisterAuthentication(ctx, user, tokenClient.UserID, token)
ctx.Next()
return
}
}
RegisterAuthentication(ctx, nil, 0, "")
ctx.Next()
}
}

View File

@@ -80,12 +80,13 @@ func (s *AuthenticationSuite) TestQueryToken() {
s.assertQueryRequest("token", "clienttoken_admin", s.auth.RequireAdmin, 200)
}
func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) {
func (s *AuthenticationSuite) assertQueryRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx, _ = gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", fmt.Sprintf("/?%s=%s", key, value), nil)
f()(ctx)
assert.Equal(s.T(), code, recorder.Code)
return
}
func (s *AuthenticationSuite) TestNothingProvided() {
@@ -160,13 +161,42 @@ func (s *AuthenticationSuite) TestBasicAuth() {
s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.RequireAdmin, 401)
}
func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) {
func (s *AuthenticationSuite) TestOptionalAuth() {
// various invalid users
ctx := s.assertQueryRequest("token", "ergerogerg", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("X-Gotify-Key", "ergerogerg", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("Authorization", "Basic bm90ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHd4", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertQueryRequest("tokenx", "clienttoken", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "apptoken_admin", s.auth.Optional, 200)
assert.Nil(s.T(), TryGetUserID(ctx))
// user existing:pw
ctx = s.assertHeaderRequest("Authorization", "Basic ZXhpc3Rpbmc6cHc=", s.auth.Optional, 200)
assert.Equal(s.T(), uint(1), *TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "clienttoken", s.auth.Optional, 200)
assert.Equal(s.T(), uint(1), *TryGetUserID(ctx))
// user admin:pw
ctx = s.assertHeaderRequest("Authorization", "Basic YWRtaW46cHc=", s.auth.Optional, 200)
assert.Equal(s.T(), uint(2), *TryGetUserID(ctx))
ctx = s.assertQueryRequest("token", "clienttoken_admin", s.auth.Optional, 200)
assert.Equal(s.T(), uint(2), *TryGetUserID(ctx))
}
func (s *AuthenticationSuite) assertHeaderRequest(key, value string, f fMiddleware, code int) (ctx *gin.Context) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx, _ = gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/", nil)
ctx.Request.Header.Set(key, value)
f()(ctx)
assert.Equal(s.T(), code, recorder.Code)
return
}
type fMiddleware func() gin.HandlerFunc

View File

@@ -14,16 +14,25 @@ func RegisterAuthentication(ctx *gin.Context, user *model.User, userID uint, tok
// GetUserID returns the user id which was previously registered by RegisterAuthentication.
func GetUserID(ctx *gin.Context) uint {
id := TryGetUserID(ctx)
if id == nil {
panic("token and user may not be null")
}
return *id
}
// TryGetUserID returns the user id or nil if one is not set.
func TryGetUserID(ctx *gin.Context) *uint {
user := ctx.MustGet("user").(*model.User)
if user == nil {
userID := ctx.MustGet("userid").(uint)
if userID == 0 {
panic("token and user may not be null")
return nil
}
return userID
return &userID
}
return user.ID
return &user.ID
}
// GetTokenID returns the tokenID.

View File

@@ -29,6 +29,7 @@ func (s *UtilSuite) Test_getID() {
assert.Panics(s.T(), func() {
s.expectUserIDWith(nil, 0, 0)
})
s.expectTryUserIDWith(nil, 0, nil)
}
func (s *UtilSuite) Test_getToken() {
@@ -44,3 +45,10 @@ func (s *UtilSuite) expectUserIDWith(user *model.User, tokenUserID, expectedID u
actualID := GetUserID(ctx)
assert.Equal(s.T(), expectedID, actualID)
}
func (s *UtilSuite) expectTryUserIDWith(user *model.User, tokenUserID uint, expectedID *uint) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
RegisterAuthentication(ctx, user, tokenUserID, "")
actualID := TryGetUserID(ctx)
assert.Equal(s.T(), expectedID, actualID)
}

View File

@@ -50,3 +50,4 @@ defaultuser: # on database creation, gotify creates an admin user
passstrength: 10 # the bcrypt password strength (higher = better but also slower)
uploadedimagesdir: data/images # the directory for storing uploaded images
pluginsdir: data/plugins # the directory where plugin resides
registration: false # enable registrations

View File

@@ -51,6 +51,7 @@ type Configuration struct {
PassStrength int `default:"10"`
UploadedImagesDir string `default:"data/images"`
PluginsDir string `default:"data/plugins"`
Registration bool `default:"false"`
}
func configFiles() []string {

View File

@@ -1615,6 +1615,7 @@
"basicAuth": []
}
],
"description": "With enabled registration: non admin users can be created without authentication.\nWith disabled registrations: users can only be created by admin users.",
"consumes": [
"application/json"
],

View File

@@ -39,7 +39,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
ImageDir: conf.UploadedImagesDir,
}
userChangeNotifier := new(api.UserChangeNotifier)
userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier}
userHandler := api.UserAPI{DB: db, PasswordStrength: conf.PassStrength, UserChangeNotifier: userChangeNotifier, Registration: conf.Registration}
pluginManager, err := plugin.NewManager(db, conf.PluginsDir, g.Group("/plugin/:id/custom/"), streamHandler)
if err != nil {
@@ -82,6 +82,8 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
}
}
g.Group("/user").Use(authentication.Optional()).POST("", userHandler.CreateUser)
g.OPTIONS("/*any")
// swagger:operation GET /version version getVersion
@@ -157,8 +159,6 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
authAdmin.GET("", userHandler.GetUsers)
authAdmin.POST("", userHandler.CreateUser)
authAdmin.DELETE("/:id", userHandler.DeleteUserByID)
authAdmin.GET("/:id", userHandler.GetUserByID)

View File

@@ -183,6 +183,13 @@ func (d *Database) AssertUserNotExist(id uint) {
}
}
// AssertUsernameNotExist asserts that the user does not exist.
func (d *Database) AssertUsernameNotExist(name string) {
if user, err := d.GetUserByName(name); assert.NoError(d.t, err) {
assert.True(d.t, user == nil, "user %d must not exist", name)
}
}
// AssertClientNotExist asserts that the client does not exist.
func (d *Database) AssertClientNotExist(id uint) {
if client, err := d.GetClientByID(id); assert.NoError(d.t, err) {

View File

@@ -42,6 +42,27 @@ export class CurrentUser {
window.localStorage.setItem(tokenKey, token);
};
public register = async (name: string, pass: string): Promise<boolean> =>
axios
.create()
.post(config.get('url') + 'user', {name, pass})
.then(() => {
this.snack('User Created. Logging in...');
this.login(name, pass);
return true;
})
.catch((error: AxiosError) => {
if (!error || !error.response) {
this.snack('No network connection or server unavailable.');
return false;
}
const {data} = error.response;
this.snack(
`Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`
);
return false;
});
public login = async (username: string, password: string) => {
this.loggedIn = false;
this.authenticating = true;

View File

@@ -67,12 +67,15 @@ class Layout extends React.Component<
private version = Layout.defaultVersion;
@observable
private navOpen = false;
@observable
private showRegister = true; //TODO https://github.com/gotify/server/pull/394#discussion_r650559205
private setNavOpen(open: boolean) {
this.navOpen = open;
}
public componentDidMount() {
this.registration = true; //TODO https://github.com/gotify/server/pull/394#discussion_r650559205
if (this.version === Layout.defaultVersion) {
axios.get(config.get('url') + 'version').then((resp: AxiosResponse<IVersion>) => {
this.version = resp.data.version;
@@ -88,7 +91,7 @@ class Layout extends React.Component<
}
public render() {
const {version, showSettings, currentTheme} = this;
const {version, showSettings, currentTheme, showRegister} = this;
const {
classes,
currentUser: {
@@ -101,7 +104,8 @@ class Layout extends React.Component<
},
} = this.props;
const theme = themeMap[currentTheme];
const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
const loginRoute = () =>
loggedIn ? <Redirect to="/" /> : <Login showRegister={showRegister} />;
return (
<MuiThemeProvider theme={theme}>
<HashRouter>

View File

@@ -7,18 +7,25 @@ import DefaultPage from '../common/DefaultPage';
import {observable} from 'mobx';
import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import RegistrationDialog from './Register';
type Props = Stores<'currentUser'> & {
showRegister: boolean;
};
@observer
class Login extends Component<Stores<'currentUser'>> {
class Login extends Component<Props> {
@observable
private username = '';
@observable
private password = '';
@observable
private registerDialog = false;
public render() {
const {username, password} = this;
const {username, password, registerDialog} = this;
return (
<DefaultPage title="Login" maxWidth={250}>
<DefaultPage title="Login" rightControl={this.registerButton()} maxWidth={250}>
<Grid item xs={12} style={{textAlign: 'center'}}>
<Container>
<form onSubmit={this.preventDefault} id="login-form">
@@ -52,6 +59,12 @@ class Login extends Component<Stores<'currentUser'>> {
</form>
</Container>
</Grid>
{registerDialog && (
<RegistrationDialog
fClose={() => (this.registerDialog = false)}
fOnSubmit={this.props.currentUser.register}
/>
)}
</DefaultPage>
);
}
@@ -61,6 +74,20 @@ class Login extends Component<Stores<'currentUser'>> {
this.props.currentUser.login(this.username, this.password);
};
private registerButton = () => {
if (this.props.showRegister)
return (
<Button
id="register"
variant="contained"
color="primary"
onClick={() => (this.registerDialog = true)}>
Register
</Button>
);
else return null;
};
private preventDefault = (e: FormEvent<HTMLFormElement>) => e.preventDefault();
}

99
ui/src/user/Register.tsx Normal file
View File

@@ -0,0 +1,99 @@
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import TextField from '@material-ui/core/TextField';
import Tooltip from '@material-ui/core/Tooltip';
import React, {ChangeEvent, Component} from 'react';
interface IProps {
name?: string;
fClose: VoidFunction;
fOnSubmit: (name: string, pass: string) => Promise<boolean>;
}
interface IState {
name: string;
pass: string;
}
export default class RegistrationDialog extends Component<IProps, IState> {
public state = {
name: '',
pass: '',
};
public render() {
const {fClose, fOnSubmit} = this.props;
const {name, pass} = this.state;
const namePresent = this.state.name.length !== 0;
const passPresent = this.state.pass.length !== 0;
const submitAndClose = (): void => {
fOnSubmit(name, pass).then((success) => {
if (success) {
fClose();
}
});
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="add-edit-user-dialog">
<DialogTitle id="form-dialog-title">Registration</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
margin="dense"
className="password"
type="password"
value={pass}
fullWidth
label="Pass *"
onChange={this.handleChange.bind(this, 'pass')}
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'name is required'
}>
<div>
<Button
className="save-create"
disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="contained">
Register
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}