Skip to content

Commit 41cde67

Browse files
feat(security): application token refresh
1 parent e33d576 commit 41cde67

9 files changed

Lines changed: 370 additions & 18 deletions

File tree

api/application.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,79 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) {
280280
})
281281
}
282282

283+
// UpdateApplicationSecurity performs security updates on an application.
284+
// swagger:operation PUT /application/{id}/security application updateAppSecurity
285+
//
286+
// Perform security updates on an application.
287+
//
288+
// Requires elevated authentication.
289+
//
290+
// ---
291+
// consumes: [application/json]
292+
// produces: [application/json]
293+
// security: [clientTokenAuthorizationHeader: [], clientTokenHeader: [], clientTokenQuery: [], basicAuth: []]
294+
// parameters:
295+
// - name: body
296+
// in: body
297+
// description: security update action descriptor
298+
// required: true
299+
// schema:
300+
// $ref: "#/definitions/SecurityUpdateAction"
301+
// - name: id
302+
// in: path
303+
// description: the application id
304+
// required: true
305+
// type: integer
306+
// format: int64
307+
// responses:
308+
// 200:
309+
// description: Ok
310+
// schema:
311+
// $ref: "#/definitions/SecurityUpdateActionResponse"
312+
// 400:
313+
// description: Bad Request
314+
// schema:
315+
// $ref: "#/definitions/Error"
316+
// 401:
317+
// description: Unauthorized
318+
// schema:
319+
// $ref: "#/definitions/Error"
320+
// 403:
321+
// description: Forbidden
322+
// schema:
323+
// $ref: "#/definitions/Error"
324+
// 404:
325+
// description: Not Found
326+
// schema:
327+
// $ref: "#/definitions/Error"
328+
// 500:
329+
// description: Server Error
330+
// schema:
331+
// $ref: "#/definitions/Error"
332+
func (a *ApplicationAPI) UpdateApplicationSecurity(ctx *gin.Context) {
333+
withID(ctx, "id", func(id uint) {
334+
app, err := a.DB.GetApplicationByID(id)
335+
if success := successOrAbort(ctx, 500, err); !success {
336+
return
337+
}
338+
action := model.SecurityUpdateAction{}
339+
response := model.SecurityUpdateActionResponse{}
340+
if err := ctx.Bind(&action); err == nil {
341+
if action.RegenerateToken {
342+
tokenPublic, tokenPrivate := generateApplicationToken()
343+
app.Token = tokenPublic
344+
response.RegenerateToken = &model.RegenerateTokenResponse{
345+
Token: tokenPrivate,
346+
}
347+
}
348+
if success := successOrAbort(ctx, 500, a.DB.UpdateApplication(app)); !success {
349+
return
350+
}
351+
ctx.JSON(200, response)
352+
}
353+
})
354+
}
355+
283356
// UploadApplicationImage uploads an image for an application.
284357
// swagger:operation POST /application/{id}/image application uploadAppImage
285358
//

api/application_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,50 @@ func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInPar
143143
}
144144
}
145145

146+
func (s *ApplicationSuite) Test_UpdateApplicationSecurity_regenerateToken() {
147+
s.db.User(5).App(1)
148+
test.WithUser(s.ctx, 5)
149+
150+
oldToken, err := s.db.GetApplicationByID(1)
151+
assert.NoError(s.T(), err)
152+
s.ctx.Request = httptest.NewRequest("PUT", "/application/1/security", bytes.NewBufferString(`{"regenerateToken": true}`))
153+
s.ctx.Request.Header.Set("Content-Type", "application/json")
154+
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
155+
s.a.UpdateApplicationSecurity(s.ctx)
156+
assert.Equal(s.T(), 200, s.recorder.Code)
157+
bodyBytes, err := io.ReadAll(s.recorder.Body)
158+
assert.Nil(s.T(), err)
159+
var got model.SecurityUpdateActionResponse
160+
assert.Nil(s.T(), json.Unmarshal(bodyBytes, &got))
161+
assert.Equal(s.T(), &model.SecurityUpdateActionResponse{
162+
RegenerateToken: &model.RegenerateTokenResponse{
163+
Token: got.RegenerateToken.Token,
164+
},
165+
}, &got)
166+
newToken, err := s.db.GetApplicationByID(1)
167+
assert.NoError(s.T(), err)
168+
assert.NotEqual(s.T(), oldToken.Token, newToken.Token)
169+
}
170+
171+
func (s *ApplicationSuite) Test_UpdateApplicationSecurity_isNoOpIfNilAction() {
172+
s.db.User(5).App(1)
173+
test.WithUser(s.ctx, 5)
174+
175+
oldToken, err := s.db.GetApplicationByID(1)
176+
assert.NoError(s.T(), err)
177+
s.ctx.Request = httptest.NewRequest("PUT", "/application/1/security", bytes.NewBufferString(`{}`))
178+
s.ctx.Request.Header.Set("Content-Type", "application/json")
179+
s.ctx.Params = gin.Params{{Key: "id", Value: "1"}}
180+
s.a.UpdateApplicationSecurity(s.ctx)
181+
assert.Equal(s.T(), 200, s.recorder.Code)
182+
bodyBytes, err := io.ReadAll(s.recorder.Body)
183+
assert.Nil(s.T(), err)
184+
assert.Equal(s.T(), "{}", string(bodyBytes))
185+
newToken, err := s.db.GetApplicationByID(1)
186+
assert.NoError(s.T(), err)
187+
assert.Equal(s.T(), oldToken, newToken)
188+
}
189+
146190
func (s *ApplicationSuite) Test_DeleteApplication_expectNotFoundOnCurrentUserIsNotOwner() {
147191
s.db.User(2)
148192
s.db.User(5).App(5)

docs/spec.json

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,93 @@
588588
}
589589
}
590590
},
591+
"/application/{id}/security": {
592+
"put": {
593+
"security": [
594+
{
595+
"clientTokenAuthorizationHeader": []
596+
},
597+
{
598+
"clientTokenHeader": []
599+
},
600+
{
601+
"clientTokenQuery": []
602+
},
603+
{
604+
"basicAuth": []
605+
}
606+
],
607+
"description": "Requires elevated authentication.",
608+
"consumes": [
609+
"application/json"
610+
],
611+
"produces": [
612+
"application/json"
613+
],
614+
"tags": [
615+
"application"
616+
],
617+
"summary": "Perform security updates on an application.",
618+
"operationId": "updateAppSecurity",
619+
"parameters": [
620+
{
621+
"description": "security update action descriptor",
622+
"name": "body",
623+
"in": "body",
624+
"required": true,
625+
"schema": {
626+
"$ref": "#/definitions/SecurityUpdateAction"
627+
}
628+
},
629+
{
630+
"type": "integer",
631+
"format": "int64",
632+
"description": "the application id",
633+
"name": "id",
634+
"in": "path",
635+
"required": true
636+
}
637+
],
638+
"responses": {
639+
"200": {
640+
"description": "Ok",
641+
"schema": {
642+
"$ref": "#/definitions/SecurityUpdateActionResponse"
643+
}
644+
},
645+
"400": {
646+
"description": "Bad Request",
647+
"schema": {
648+
"$ref": "#/definitions/Error"
649+
}
650+
},
651+
"401": {
652+
"description": "Unauthorized",
653+
"schema": {
654+
"$ref": "#/definitions/Error"
655+
}
656+
},
657+
"403": {
658+
"description": "Forbidden",
659+
"schema": {
660+
"$ref": "#/definitions/Error"
661+
}
662+
},
663+
"404": {
664+
"description": "Not Found",
665+
"schema": {
666+
"$ref": "#/definitions/Error"
667+
}
668+
},
669+
"500": {
670+
"description": "Server Error",
671+
"schema": {
672+
"$ref": "#/definitions/Error"
673+
}
674+
}
675+
}
676+
}
677+
},
591678
"/auth/local/login": {
592679
"post": {
593680
"security": [
@@ -3192,6 +3279,49 @@
31923279
"x-go-name": "PluginConfExternal",
31933280
"x-go-package": "github.com/gotify/server/v2/model"
31943281
},
3282+
"RegenerateTokenResponse": {
3283+
"description": "The RegenerateTokenResponse holds information about the response to the regenerate token action.",
3284+
"type": "object",
3285+
"title": "RegenerateTokenResponse Model",
3286+
"required": [
3287+
"token"
3288+
],
3289+
"properties": {
3290+
"token": {
3291+
"description": "The new token.",
3292+
"type": "string",
3293+
"x-go-name": "Token",
3294+
"readOnly": true,
3295+
"example": "gtfya.e2NcJK7AenXBPIRB3S03JsBlmy0V6xP8h0hwSiAJae8"
3296+
}
3297+
},
3298+
"x-go-package": "github.com/gotify/server/v2/model"
3299+
},
3300+
"SecurityUpdateAction": {
3301+
"description": "The SecurityUpdateAction describes the details of a requested security update.",
3302+
"type": "object",
3303+
"title": "SecurityUpdateAction Model",
3304+
"properties": {
3305+
"regenerateToken": {
3306+
"description": "Whether to regenerate the token. Your client token must be elevated to perform this action.",
3307+
"type": "boolean",
3308+
"x-go-name": "RegenerateToken",
3309+
"example": true
3310+
}
3311+
},
3312+
"x-go-package": "github.com/gotify/server/v2/model"
3313+
},
3314+
"SecurityUpdateActionResponse": {
3315+
"description": "The SecurityUpdateActionResponse holds information about the response to a security update request.",
3316+
"type": "object",
3317+
"title": "SecurityUpdateActionResponse Model",
3318+
"properties": {
3319+
"regenerateToken": {
3320+
"$ref": "#/definitions/RegenerateTokenResponse"
3321+
}
3322+
},
3323+
"x-go-package": "github.com/gotify/server/v2/model"
3324+
},
31953325
"UpdateUserExternal": {
31963326
"description": "Used for updating a user.",
31973327
"type": "object",

model/security.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package model
2+
3+
// SecurityUpdateAction Model
4+
//
5+
// The SecurityUpdateAction describes the details of a requested security update.
6+
//
7+
// swagger:model SecurityUpdateAction
8+
type SecurityUpdateAction struct {
9+
// Whether to regenerate the token. Your client token must be elevated to perform this action.
10+
//
11+
// example: true
12+
RegenerateToken bool `form:"regenerateToken" query:"regenerateToken" json:"regenerateToken"`
13+
}
14+
15+
// SecurityUpdateActionResponse Model
16+
//
17+
// The SecurityUpdateActionResponse holds information about the response to a security update request.
18+
//
19+
// swagger:model SecurityUpdateActionResponse
20+
type SecurityUpdateActionResponse struct {
21+
// The response to the regenerate token action. Only present if the regenerate token action was requested.
22+
RegenerateToken *RegenerateTokenResponse `json:"regenerateToken,omitempty"`
23+
}
24+
25+
// RegenerateTokenResponse Model
26+
//
27+
// The RegenerateTokenResponse holds information about the response to the regenerate token action.
28+
//
29+
// swagger:model RegenerateTokenResponse
30+
type RegenerateTokenResponse struct {
31+
// The new token.
32+
//
33+
// example: gtfya.e2NcJK7AenXBPIRB3S03JsBlmy0V6xP8h0hwSiAJae8
34+
// read only: true
35+
// required: true
36+
Token string `json:"token"`
37+
}

router/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
232232
clientElevated.Use(authentication.RequireElevatedClient)
233233
clientElevated.POST("/client/:id/elevate", clientHandler.ElevateClient)
234234
clientElevated.DELETE("/application/:id", applicationHandler.DeleteApplication)
235+
clientElevated.PUT("/application/:id/security", applicationHandler.UpdateApplicationSecurity)
235236
clientElevated.DELETE("/client/:id", clientHandler.DeleteClient)
236237
clientElevated.POST("/current/user/password", userHandler.ChangePassword)
237238
}

ui/src/application/AddApplicationDialog.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import {copyToClipboard} from '../clipboard';
1313
import {useStores} from '../stores';
1414

1515
interface IProps {
16+
fKnownToken?: string;
1617
fClose: VoidFunction;
1718
fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<string>;
1819
}
1920

20-
export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
21-
const [returnToken, setReturnToken] = useState('');
21+
export const AddApplicationDialog = ({fClose, fOnSubmit, fKnownToken}: IProps) => {
22+
const [returnToken, setReturnToken] = useState(fKnownToken || '');
2223
const [name, setName] = useState('');
2324
const [description, setDescription] = useState('');
2425
const [defaultPriority, setDefaultPriority] = useState(0);
@@ -32,7 +33,9 @@ export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
3233

3334
return (
3435
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
35-
<DialogTitle id="form-dialog-title">Create an application</DialogTitle>
36+
<DialogTitle id="form-dialog-title">
37+
{fKnownToken ? 'Update an application' : 'Create an application'}
38+
</DialogTitle>
3639
<DialogContent>
3740
{returnToken ? (
3841
<>

ui/src/application/AppStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export class AppStore extends BaseStore<IApplication> {
3636
this.snack('Application image updated');
3737
};
3838

39+
public async rekey(id: number): Promise<string> {
40+
const response = await axios.put(`${config.get('url')}application/${id}/security`, {
41+
regenerateToken: true,
42+
});
43+
return response.data.regenerateToken.token;
44+
}
45+
3946
public async deleteImage(id: number): Promise<void> {
4047
try {
4148
await axios.delete(`${config.get('url')}application/${id}/image`);

0 commit comments

Comments
 (0)