Browse Source

Integrated password protection for shortened links (fix #30)

dependabot/npm_and_yarn/web/prismjs-1.21.0
Max Schmitt 8 years ago
parent
commit
87c3ce5b1e
  1. 2
      Makefile
  2. 2
      handlers/auth.go
  3. 2
      handlers/auth/auth.go
  4. 2
      handlers/auth_test.go
  5. 7
      handlers/handlers.go
  6. 41
      handlers/public.go
  7. 12
      handlers/public_test.go
  8. 68
      handlers/tmpls/protected.html
  9. 0
      handlers/tmpls/token.html
  10. 1
      main.go
  11. 4
      static/package.json
  12. 5
      static/src/Home/Home.css
  13. 31
      static/src/Home/Home.js
  14. 10
      static/src/index.js
  15. 23
      store/store.go

2
Makefile

@ -9,7 +9,7 @@ buildNodeFrontend:
@cd static && rm build/static/**/*.map @cd static && rm build/static/**/*.map
embedFrontend: embedFrontend:
@cd handlers/tmpls && esc -o tmpls.go -pkg tmpls -include ^*\.tmpl . @cd handlers/tmpls && esc -o tmpls.go -pkg tmpls -include ^*\.html .
@cd handlers && esc -o static.go -pkg handlers -prefix ../static/build ../static/build @cd handlers && esc -o static.go -pkg handlers -prefix ../static/build ../static/build
bash build/info.sh bash build/info.sh

2
handlers/auth.go

@ -33,7 +33,7 @@ func (h *Handler) initOAuth() {
h.providers = append(h.providers, "microsoft") h.providers = append(h.providers, "microsoft")
} }
h.engine.POST("/api/v1/check", h.handleAuthCheck) h.engine.POST("/api/v1/auth/check", h.handleAuthCheck)
} }
func (h *Handler) parseJWT(wt string) (*auth.JWTClaims, error) { func (h *Handler) parseJWT(wt string) (*auth.JWTClaims, error) {

2
handlers/auth/auth.go

@ -88,7 +88,7 @@ func (a *AdapterWrapper) HandleCallback(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.HTML(http.StatusOK, "token.tmpl", gin.H{ c.HTML(http.StatusOK, "token.html", gin.H{
"token": token, "token": token,
}) })
} }

2
handlers/auth_test.go

@ -99,7 +99,7 @@ func TestCheckToken(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("could not post to the backend: %v", err) t.Fatalf("could not post to the backend: %v", err)
} }
resp, err := http.Post(server.URL+"/api/v1/check", "application/json", bytes.NewBuffer(body)) resp, err := http.Post(server.URL+"/api/v1/auth/check", "application/json", bytes.NewBuffer(body))
if err != nil { if err != nil {
t.Fatalf("could not execute get request: %v", err) t.Fatalf("could not execute get request: %v", err)
} }

7
handlers/handlers.go

@ -62,8 +62,11 @@ func (h *Handler) setTemplateFromFS(name string) error {
} }
func (h *Handler) setHandlers() error { func (h *Handler) setHandlers() error {
if err := h.setTemplateFromFS("token.tmpl"); err != nil { templates := []string{"token.html", "protected.html"}
return errors.Wrap(err, "could not set template from FS") for _, template := range templates {
if err := h.setTemplateFromFS(template); err != nil {
return errors.Wrapf(err, "could not set template %s from FS", template)
}
} }
h.engine.Use(ginrus.Ginrus(logrus.StandardLogger(), time.RFC3339, false)) h.engine.Use(ginrus.Ginrus(logrus.StandardLogger(), time.RFC3339, false))
protected := h.engine.Group("/api/v1/protected") protected := h.engine.Group("/api/v1/protected")

41
handlers/public.go

@ -14,13 +14,14 @@ import (
"github.com/maxibanki/golang-url-shortener/handlers/auth" "github.com/maxibanki/golang-url-shortener/handlers/auth"
"github.com/maxibanki/golang-url-shortener/store" "github.com/maxibanki/golang-url-shortener/store"
"github.com/maxibanki/golang-url-shortener/util" "github.com/maxibanki/golang-url-shortener/util"
"golang.org/x/crypto/bcrypt"
) )
// urlUtil is used to help in- and outgoing requests for json // requestHelper is used to help in- and outgoing requests for json
// un- and marshalling // un- and marshalling
type urlUtil struct { type requestHelper struct {
URL string `binding:"required"` URL string `binding:"required"`
ID, DeletionURL string ID, DeletionURL, Password string
Expiration *time.Time Expiration *time.Time
} }
@ -52,7 +53,7 @@ func (h *Handler) handleLookup(c *gin.Context) {
// handleAccess handles the access for incoming requests // handleAccess handles the access for incoming requests
func (h *Handler) handleAccess(c *gin.Context) { func (h *Handler) handleAccess(c *gin.Context) {
id := c.Request.URL.Path[1:] id := c.Request.URL.Path[1:]
url, err := h.store.GetURLAndIncrease(id) entry, err := h.store.GetEntryAndIncrease(id)
if err == store.ErrNoEntryFound { if err == store.ErrNoEntryFound {
return return
} else if err != nil { } else if err != nil {
@ -70,7 +71,31 @@ func (h *Handler) handleAccess(c *gin.Context) {
UTMContent: c.Query("utm_content"), UTMContent: c.Query("utm_content"),
UTMTerm: c.Query("utm_term"), UTMTerm: c.Query("utm_term"),
}) })
c.Redirect(http.StatusTemporaryRedirect, url) // No password set
if len(entry.Password) == 0 {
c.Redirect(http.StatusTemporaryRedirect, entry.Public.URL)
} else {
templateError := ""
if c.Request.Method == "POST" {
pw, exists := c.GetPostForm("password")
if exists {
if err := bcrypt.CompareHashAndPassword(entry.Password, []byte(pw)); err != nil {
templateError = fmt.Sprintf("could not validate password: %v", err)
}
} else {
templateError = "No password set"
}
if templateError == "" {
c.Redirect(http.StatusTemporaryRedirect, entry.Public.URL)
c.Abort()
return
}
}
c.HTML(http.StatusOK, "protected.html", gin.H{
"ID": id,
"Error": templateError,
})
}
// There is a need to Abort in the current middleware to prevent // There is a need to Abort in the current middleware to prevent
// that the status code will be overridden by the default NoRoute handler // that the status code will be overridden by the default NoRoute handler
c.Abort() c.Abort()
@ -78,7 +103,7 @@ func (h *Handler) handleAccess(c *gin.Context) {
// handleCreate handles requests to create an entry // handleCreate handles requests to create an entry
func (h *Handler) handleCreate(c *gin.Context) { func (h *Handler) handleCreate(c *gin.Context) {
var data urlUtil var data requestHelper
if err := c.ShouldBind(&data); err != nil { if err := c.ShouldBind(&data); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@ -92,13 +117,13 @@ func (h *Handler) handleCreate(c *gin.Context) {
RemoteAddr: c.ClientIP(), RemoteAddr: c.ClientIP(),
OAuthProvider: user.OAuthProvider, OAuthProvider: user.OAuthProvider,
OAuthID: user.OAuthID, OAuthID: user.OAuthID,
}, data.ID) }, data.ID, data.Password)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
originURL := h.getURLOrigin(c) originURL := h.getURLOrigin(c)
c.JSON(http.StatusOK, urlUtil{ c.JSON(http.StatusOK, requestHelper{
URL: fmt.Sprintf("%s/%s", originURL, id), URL: fmt.Sprintf("%s/%s", originURL, id),
DeletionURL: fmt.Sprintf("%s/d/%s/%s", originURL, id, url.QueryEscape(base64.RawURLEncoding.EncodeToString(delID))), DeletionURL: fmt.Sprintf("%s/d/%s/%s", originURL, id, url.QueryEscape(base64.RawURLEncoding.EncodeToString(delID))),
}) })

12
handlers/public_test.go

@ -25,7 +25,7 @@ func TestCreateEntry(t *testing.T) {
ignoreResponse bool ignoreResponse bool
contentType string contentType string
response gin.H response gin.H
requestBody urlUtil requestBody requestHelper
statusCode int statusCode int
}{ }{
{ {
@ -37,7 +37,7 @@ func TestCreateEntry(t *testing.T) {
}, },
{ {
name: "short URL generation", name: "short URL generation",
requestBody: urlUtil{ requestBody: requestHelper{
URL: "https://www.google.de/", URL: "https://www.google.de/",
}, },
statusCode: http.StatusOK, statusCode: http.StatusOK,
@ -45,7 +45,7 @@ func TestCreateEntry(t *testing.T) {
}, },
{ {
name: "no valid URL", name: "no valid URL",
requestBody: urlUtil{ requestBody: requestHelper{
URL: "this is really not a URL", URL: "this is really not a URL",
}, },
statusCode: http.StatusBadRequest, statusCode: http.StatusBadRequest,
@ -76,7 +76,7 @@ func TestCreateEntry(t *testing.T) {
if tc.ignoreResponse { if tc.ignoreResponse {
return return
} }
var parsed urlUtil var parsed requestHelper
if err := json.Unmarshal(respBody, &parsed); err != nil { if err := json.Unmarshal(respBody, &parsed); err != nil {
t.Fatalf("could not unmarshal data: %v", err) t.Fatalf("could not unmarshal data: %v", err)
} }
@ -96,7 +96,7 @@ func TestHandleInfo(t *testing.T) {
t.Fatalf("could not marshal json: %v", err) t.Fatalf("could not marshal json: %v", err)
} }
respBody := createEntryWithJSON(t, reqBody, "application/json; charset=utf-8", http.StatusOK) respBody := createEntryWithJSON(t, reqBody, "application/json; charset=utf-8", http.StatusOK)
var parsed urlUtil var parsed requestHelper
if err = json.Unmarshal(respBody, &parsed); err != nil { if err = json.Unmarshal(respBody, &parsed); err != nil {
t.Fatalf("could not unmarshal data: %v", err) t.Fatalf("could not unmarshal data: %v", err)
} }
@ -257,7 +257,7 @@ func TestHandleDeletion(t *testing.T) {
t.Fatalf("could not marshal json: %v", err) t.Fatalf("could not marshal json: %v", err)
} }
respBody := createEntryWithJSON(t, reqBody, "application/json; charset=utf-8", http.StatusOK) respBody := createEntryWithJSON(t, reqBody, "application/json; charset=utf-8", http.StatusOK)
var body urlUtil var body requestHelper
if err := json.Unmarshal(respBody, &body); err != nil { if err := json.Unmarshal(respBody, &body); err != nil {
t.Fatal("could not unmarshal create response") t.Fatal("could not unmarshal create response")
} }

68
handlers/tmpls/protected.html

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Authorize please</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.2.13/semantic.min.css" />
<style type="text/css">
body {
background-color: #DADADA;
}
body>.grid {
height: 100%;
}
.image {
margin-top: -100px;
}
.column {
max-width: 450px;
}
</style>
</head>
<body>
<div class="ui middle aligned center aligned grid">
<div class="column">
<h2 class="ui image header">
<i class="massive lock icon"></i>
<div class="content">
Visit the URL with the ID {{ .ID }}
</div>
</h2>
<form class="ui large form" method="POST">
<div class="ui stacked segment">
<div class="field">
<div class="ui left icon input">
<i class="key icon"></i>
<input type="password" name="password" placeholder="Password" required>
</div>
</div>
<button class="ui fluid large button" type="submit">Login</button>
</div>
</form>
{{ if .Error }}
<div class="ui negative message">
<i class="close icon"></i>
<div class="header">
Authorization error occured
</div>
<p>{{ .Error }}</p>
</div>
{{ end }}
<div class="ui message">
New to us and want to create an own shortened URL?
<a href="/">Sign Up</a>
</div>
</div>
</div>
<script>
</script>
</body>
</html>

0
handlers/tmpls/token.tmpl → handlers/tmpls/token.html

1
main.go

@ -14,7 +14,6 @@ import (
) )
func main() { func main() {
os.Setenv("GUS_SHORTED_ID_LENGTH", "4")
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt) signal.Notify(stop, os.Interrupt)
logrus.SetFormatter(&logrus.TextFormatter{ logrus.SetFormatter(&logrus.TextFormatter{

4
static/package.json

@ -6,6 +6,10 @@
"/api": { "/api": {
"target": "http://127.0.0.1:8080", "target": "http://127.0.0.1:8080",
"ws": true "ws": true
},
"/d": {
"target": "http://127.0.0.1:8080",
"ws": true
} }
}, },
"dependencies": { "dependencies": {

5
static/src/Home/Home.css

@ -4,3 +4,8 @@
.react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list { .react-datepicker__time-container .react-datepicker__time .react-datepicker__time-box ul.react-datepicker__time-list {
padding: 0 0; padding: 0 0;
} }
@media only screen and (max-width: 767px) {
.FieldsMarginButtomFix {
margin-bottom: 1rem !important;
}
}

31
static/src/Home/Home.js

@ -11,6 +11,7 @@ import './Home.css'
export default class HomeComponent extends Component { export default class HomeComponent extends Component {
handleURLChange = (e, { value }) => this.url = value handleURLChange = (e, { value }) => this.url = value
handlePasswordChange = (e, { value }) => this.password = value
handleCustomExpirationChange = expire => this.setState({ expiration: expire }) handleCustomExpirationChange = expire => this.setState({ expiration: expire })
handleCustomIDChange = (e, { value }) => { handleCustomIDChange = (e, { value }) => {
this.customID = value this.customID = value
@ -30,7 +31,6 @@ export default class HomeComponent extends Component {
}) })
.catch(e => { .catch(e => {
this.setState({ showCustomIDError: false }) this.setState({ showCustomIDError: false })
toastr.error(`Could not fetch lookup: ${e}`)
}) })
} }
onSettingsChange = (e, { value }) => this.setState({ setOptions: value }) onSettingsChange = (e, { value }) => this.setState({ setOptions: value })
@ -39,11 +39,12 @@ export default class HomeComponent extends Component {
links: [], links: [],
options: [ options: [
{ text: 'Custom URL', value: 'custom' }, { text: 'Custom URL', value: 'custom' },
{ text: 'Expiration', value: 'expire' } { text: 'Expiration', value: 'expire' },
{ text: 'Password', value: 'protected' }
], ],
setOptions: [], setOptions: [],
showCustomIDError: false, showCustomIDError: false,
expiration: moment() expiration: null
} }
componentDidMount() { componentDidMount() {
this.urlInput.focus() this.urlInput.focus()
@ -55,7 +56,8 @@ export default class HomeComponent extends Component {
body: JSON.stringify({ body: JSON.stringify({
URL: this.url, URL: this.url,
ID: this.customID, ID: this.customID,
Expiration: this.state.setOptions.indexOf("expire") > -1 ? this.state.expiration.toISOString() : undefined Expiration: this.state.setOptions.includes("expire") && this.state.expiration ? this.state.expiration.toISOString() : undefined,
Password: this.state.setOptions.includes("protected") && this.password ? this.password : undefined
}), }),
headers: { headers: {
'Authorization': window.localStorage.getItem('token'), 'Authorization': window.localStorage.getItem('token'),
@ -67,11 +69,11 @@ export default class HomeComponent extends Component {
links: [...this.state.links, [ links: [...this.state.links, [
r.URL, r.URL,
this.url, this.url,
this.state.setOptions.indexOf("expire") > -1 ? this.state.expiration.toISOString() : undefined, this.state.setOptions.includes("expire") && this.state.expiration ? this.state.expiration.toISOString() : undefined,
r.DeletionURL r.DeletionURL
]] ]]
})) }))
.catch(e => toastr.error(`Could not fetch create: ${e}`)) .catch(e => e instanceof Promise ? e.then(error => toastr.error(`Could not fetch lookup: ${error.error}`)) : toastr.error(`Could not fetch create: ${e}`))
} }
} }
@ -96,16 +98,25 @@ export default class HomeComponent extends Component {
<Select options={options} placeholder='Settings' onChange={this.onSettingsChange} multiple fluid /> <Select options={options} placeholder='Settings' onChange={this.onSettingsChange} multiple fluid />
</Form.Field> </Form.Field>
</MediaQuery> </MediaQuery>
<Form.Group widths='equal'> <Form.Group className="FieldsMarginButtomFix">
{setOptions.indexOf("custom") > -1 && <Form.Field error={showCustomIDError}> {setOptions.includes("custom") && <Form.Field error={showCustomIDError} width={16}>
<Input label={window.location.origin + "/"} onChange={this.handleCustomIDChange} placeholder='my-shortened-url' /> <Input label={window.location.origin + "/"} onChange={this.handleCustomIDChange} placeholder='my-shortened-url' />
</Form.Field>} </Form.Field>}
{setOptions.indexOf("expire") > -1 && <Form.Field> </Form.Group>
<Form.Group widths="equal">
{setOptions.includes("expire") && <Form.Field>
<DatePicker showTimeSelect <DatePicker showTimeSelect
timeFormat="HH:mm" timeFormat="HH:mm"
timeIntervals={15} timeIntervals={15}
dateFormat="LLL" onChange={this.handleCustomExpirationChange} selected={expiration} customInput={<Input label="Expiration" />} minDate={moment()} /> placeholderText="Click to select a date"
dateFormat="LLL"
onChange={this.handleCustomExpirationChange}
selected={expiration}
customInput={<Input label="Expiration" />}
minDate={moment()} />
</Form.Field>} </Form.Field>}
{setOptions.includes("protected") && <Form.Field>
<Input type="password" label='Password' onChange={this.handlePasswordChange} autoComplete="off" /></Form.Field>}
</Form.Group> </Form.Group>
</Form> </Form>
</Segment> </Segment>

10
static/src/index.js

@ -40,7 +40,7 @@ export default class BaseComponent extends Component {
const that = this, const that = this,
token = window.localStorage.getItem('token'); token = window.localStorage.getItem('token');
if (token) { if (token) {
fetch('/api/v1/check', { fetch('/api/v1/auth/check', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
Token: token Token: token
@ -106,18 +106,18 @@ export default class BaseComponent extends Component {
<p>The following authentication services are currently available:</p> <p>The following authentication services are currently available:</p>
{info && <div className='ui center aligned segment'> {info && <div className='ui center aligned segment'>
{info.providers.length === 0 && <p>There are currently no correct oAuth credentials maintained.</p>} {info.providers.length === 0 && <p>There are currently no correct oAuth credentials maintained.</p>}
{info.providers.indexOf("google") !== -1 && <div> {info.providers.includes("google") && <div>
<Button className='ui google plus button' onClick={this.onOAuthClick.bind(this, "google")}> <Button className='ui google plus button' onClick={this.onOAuthClick.bind(this, "google")}>
<Icon name='google' /> Login with Google <Icon name='google' /> Login with Google
</Button> </Button>
{info.providers.indexOf("github") !== -1 && <div className="ui divider"></div>} {info.providers.includes("github") && <div className="ui divider"></div>}
</div>} </div>}
{info.providers.indexOf("github") !== -1 && <div> {info.providers.includes("github") && <div>
<Button style={{ backgroundColor: "#333", color: "white" }} onClick={this.onOAuthClick.bind(this, "github")}> <Button style={{ backgroundColor: "#333", color: "white" }} onClick={this.onOAuthClick.bind(this, "github")}>
<Icon name='github' /> Login with GitHub <Icon name='github' /> Login with GitHub
</Button> </Button>
</div>} </div>}
{info.providers.indexOf("microsoft") !== -1 && <div> {info.providers.includes("microsoft") && <div>
<div className="ui divider"></div> <div className="ui divider"></div>
<Button style={{ backgroundColor: "#0067b8", color: "white" }} onClick={this.onOAuthClick.bind(this, "microsoft")}> <Button style={{ backgroundColor: "#0067b8", color: "white" }} onClick={this.onOAuthClick.bind(this, "microsoft")}>
<Icon name='windows' /> Login with Microsoft <Icon name='windows' /> Login with Microsoft

23
store/store.go

@ -12,6 +12,7 @@ import (
"github.com/maxibanki/golang-url-shortener/util" "github.com/maxibanki/golang-url-shortener/util"
"github.com/pborman/uuid" "github.com/pborman/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/boltdb/bolt" "github.com/boltdb/bolt"
@ -29,6 +30,7 @@ type Entry struct {
OAuthProvider, OAuthID string OAuthProvider, OAuthID string
RemoteAddr string `json:",omitempty"` RemoteAddr string `json:",omitempty"`
DeletionURL string `json:",omitempty"` DeletionURL string `json:",omitempty"`
Password []byte `json:",omitempty"`
Public EntryPublicData Public EntryPublicData
} }
@ -117,20 +119,20 @@ func (s *Store) IncreaseVisitCounter(id string) error {
}) })
} }
// GetURLAndIncrease Increases the visitor count, checks // GetEntryAndIncrease Increases the visitor count, checks
// if the URL is expired and returns the origin URL // if the URL is expired and returns the origin URL
func (s *Store) GetURLAndIncrease(id string) (string, error) { func (s *Store) GetEntryAndIncrease(id string) (*Entry, error) {
entry, err := s.GetEntryByID(id) entry, err := s.GetEntryByID(id)
if err != nil { if err != nil {
return "", err return nil, err
} }
if entry.Public.Expiration != nil && time.Now().After(*entry.Public.Expiration) { if entry.Public.Expiration != nil && time.Now().After(*entry.Public.Expiration) {
return "", ErrEntryIsExpired return nil, ErrEntryIsExpired
} }
if err := s.IncreaseVisitCounter(id); err != nil { if err := s.IncreaseVisitCounter(id); err != nil {
return "", errors.Wrap(err, "could not increase visitor counter") return nil, errors.Wrap(err, "could not increase visitor counter")
} }
return entry.Public.URL, nil return entry, nil
} }
// GetEntryByIDRaw returns the raw data (JSON) of a data set // GetEntryByIDRaw returns the raw data (JSON) of a data set
@ -146,10 +148,17 @@ func (s *Store) GetEntryByIDRaw(id string) ([]byte, error) {
} }
// CreateEntry creates a new record and returns his short id // CreateEntry creates a new record and returns his short id
func (s *Store) CreateEntry(entry Entry, givenID string) (string, []byte, error) { func (s *Store) CreateEntry(entry Entry, givenID, password string) (string, []byte, error) {
if !govalidator.IsURL(entry.Public.URL) { if !govalidator.IsURL(entry.Public.URL) {
return "", nil, ErrNoValidURL return "", nil, ErrNoValidURL
} }
if password != "" {
var err error
entry.Password, err = bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return "", nil, errors.Wrap(err, "could not generate bcrypt from password")
}
}
// try it 10 times to make a short URL // try it 10 times to make a short URL
for i := 1; i <= 10; i++ { for i := 1; i <= 10; i++ {
id, delID, err := s.createEntry(entry, givenID) id, delID, err := s.createEntry(entry, givenID)

Loading…
Cancel
Save