mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
feat(auth): sync avatar from OpenID providers (#821)
This commit is contained in:
@@ -139,7 +139,8 @@
|
||||
"uploadAvatar": "Upload Avatar",
|
||||
"statusUpdateSuccess": "Avatar status was updated successfully!",
|
||||
"setSuccess": "The avatar has been set successfully!",
|
||||
"ldap": "Your avatar is automagically synced from your organization's directory service (LDAP). You can ask your IT team for information on how to change it."
|
||||
"ldap": "Your avatar is automagically synced from your organization's directory service (LDAP). You can ask your IT team for information on how to change it.",
|
||||
"openid": "Your avatar is automagically synced from your login provider ({provider}). To change it, please update it there instead."
|
||||
},
|
||||
"quickAddMagic": {
|
||||
"title": "Quick Add Magic Mode",
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
{{ $t('user.settings.avatar.ldap') }}
|
||||
</Message>
|
||||
|
||||
<Message v-else-if="avatarProvider === 'openid'">
|
||||
{{ $t('user.settings.avatar.openid', {provider: authStore.info.authProvider}) }}
|
||||
</Message>
|
||||
|
||||
<template v-else>
|
||||
<div class="control mb-4">
|
||||
<label
|
||||
|
||||
@@ -21,10 +21,6 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
@@ -34,6 +30,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"xorm.io/xorm"
|
||||
@@ -181,7 +178,7 @@ func AuthenticateUserInLDAP(s *xorm.Session, username, password string, syncGrou
|
||||
u.AvatarProvider = "ldap"
|
||||
|
||||
// Process the avatar image to ensure 1:1 aspect ratio
|
||||
processedAvatar, err := cropAvatarTo1x1(raw)
|
||||
processedAvatar, err := utils.CropAvatarTo1x1(raw)
|
||||
if err != nil {
|
||||
log.Debugf("Error processing LDAP avatar: %v", err)
|
||||
// Continue without avatar if processing fails
|
||||
@@ -311,63 +308,3 @@ func syncUserGroups(l *ldap.Conn, u *user.User, userdn string) (err error) {
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// cropAvatarTo1x1 crops the avatar image to a 1:1 aspect ratio, centered on the image
|
||||
func cropAvatarTo1x1(imageData []byte) ([]byte, error) {
|
||||
if len(imageData) == 0 {
|
||||
return nil, errors.New("empty image data")
|
||||
}
|
||||
|
||||
// Decode the image
|
||||
img, format, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Get image dimensions
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
// If already square, return original
|
||||
if width == height {
|
||||
return imageData, nil
|
||||
}
|
||||
|
||||
// Determine the crop size (use the smaller dimension)
|
||||
size := width
|
||||
if height < width {
|
||||
size = height
|
||||
}
|
||||
|
||||
// Calculate crop coordinates to center the image
|
||||
x0 := (width - size) / 2
|
||||
y0 := (height - size) / 2
|
||||
x1 := x0 + size
|
||||
y1 := y0 + size
|
||||
|
||||
// Create the cropping rectangle
|
||||
cropRect := image.Rect(x0, y0, x1, y1)
|
||||
|
||||
// Create a new RGBA image
|
||||
croppedImg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
// Copy the cropped portion
|
||||
draw.Draw(croppedImg, croppedImg.Bounds(), img, cropRect.Min, draw.Src)
|
||||
|
||||
// Encode the result
|
||||
var buf bytes.Buffer
|
||||
switch format {
|
||||
case "jpeg":
|
||||
err = jpeg.Encode(&buf, croppedImg, nil)
|
||||
default:
|
||||
// Default to PNG if format is unknown
|
||||
err = png.Encode(&buf, croppedImg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode cropped image: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
package openid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -28,7 +30,9 @@ import (
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -68,6 +72,7 @@ type claims struct {
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Nickname string `json:"nickname"`
|
||||
VikunjaGroups []map[string]interface{} `json:"vikunja_groups"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -215,6 +220,39 @@ func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (
|
||||
return teamData
|
||||
}
|
||||
|
||||
// Download and store a user's avatar from an OpenID provider
|
||||
func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string) (err error) {
|
||||
// Don't sync avatar if no picture URL is provided
|
||||
if pictureURL == "" {
|
||||
return fmt.Errorf("no picture URL provided")
|
||||
}
|
||||
|
||||
log.Debugf("Found avatar URL for user %s: %s", u.Username, pictureURL)
|
||||
|
||||
// Download avatar
|
||||
avatarData, err := utils.DownloadImage(pictureURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error downloading avatar: %w", err)
|
||||
}
|
||||
|
||||
// Process avatar, ensure 1:1 ratio
|
||||
processedAvatar, err := utils.CropAvatarTo1x1(avatarData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error processing avatar: %w", err)
|
||||
}
|
||||
|
||||
// Set avatar provider to openid
|
||||
u.AvatarProvider = "openid"
|
||||
|
||||
// Store avatar and update user
|
||||
err = upload.StoreAvatarFile(s, u, bytes.NewReader(processedAvatar))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error storing avatar: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
|
||||
|
||||
// set defaults
|
||||
@@ -270,7 +308,10 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
|
||||
Issuer: idToken.Issuer,
|
||||
Subject: idToken.Subject,
|
||||
}
|
||||
return auth.CreateUserWithRandomUsername(s, uu)
|
||||
u, err = auth.CreateUserWithRandomUsername(s, uu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if alreadyCreatedFromIssuer {
|
||||
|
||||
// try updating user.Name and/or user.Email if necessary
|
||||
@@ -286,7 +327,13 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
// Try sync avatar if available
|
||||
err = syncUserAvatarFromOpenID(s, u, cl.Picture)
|
||||
if err != nil {
|
||||
log.Errorf("Error syncing avatar for user %s: %v", u.Username, err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// mergeClaims combines claims from token and userinfo based on the ForceUserInfo setting
|
||||
@@ -308,6 +355,10 @@ func mergeClaims(cl *claims, cl2 *claims, forceUserInfo bool) error {
|
||||
cl.PreferredUsername = cl2.Nickname
|
||||
}
|
||||
|
||||
if (forceUserInfo && cl2.Picture != "") || cl.Picture == "" {
|
||||
cl.Picture = cl2.Picture
|
||||
}
|
||||
|
||||
if cl.Email == "" {
|
||||
return &user.ErrNoOpenIDEmailProvided{}
|
||||
}
|
||||
@@ -324,7 +375,7 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if provider.ForceUserInfo || cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
|
||||
if provider.ForceUserInfo || cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" || cl.Picture == "" {
|
||||
info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
|
||||
if err != nil {
|
||||
log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)
|
||||
|
||||
30
pkg/modules/avatar/openid/openid.go
Normal file
30
pkg/modules/avatar/openid/openid.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package openid
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) GetAvatar(user *user.User, size int64) (avatar []byte, mimeType string, err error) {
|
||||
up := upload.Provider{}
|
||||
|
||||
return up.GetAvatar(user, size)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/modules/avatar/initials"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/ldap"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/marble"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/openid"
|
||||
"code.vikunja.io/api/pkg/modules/avatar/upload"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
@@ -79,6 +80,8 @@ func GetAvatar(c echo.Context) error {
|
||||
avatarProvider = &marble.Provider{}
|
||||
case "ldap":
|
||||
avatarProvider = &ldap.Provider{}
|
||||
case "openid":
|
||||
avatarProvider = &openid.Provider{}
|
||||
default:
|
||||
avatarProvider = &empty.Provider{}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
|
||||
// UserAvatarProvider holds the user avatar provider type
|
||||
type UserAvatarProvider struct {
|
||||
// The avatar provider. Valid types are `gravatar` (uses the user email), `upload`, `initials`, `marble` (generates a random avatar for each user), `default`.
|
||||
// The avatar provider. Valid types are `gravatar` (uses the user email), `upload`, `initials`, `marble` (generates a random avatar for each user), `ldap` (synced from LDAP server), `openid` (synced from OpenID provider), `default`.
|
||||
AvatarProvider string `json:"avatar_provider"`
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func GetUserAvatarProvider(c echo.Context) error {
|
||||
|
||||
// ChangeUserAvatarProvider changes the user's avatar provider
|
||||
// @Summary Set the user's avatar
|
||||
// @Description Changes the user avatar. Valid types are gravatar (uses the user email), upload, initials, default.
|
||||
// @Description Changes the user avatar. Valid types are gravatar (uses the user email), upload, initials, marble, ldap (synced from LDAP server), openid (synced from OpenID provider), default.
|
||||
// @tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
|
||||
@@ -551,7 +551,8 @@ func UpdateUser(s *xorm.Session, user *User, forceOverride bool) (updatedUser *U
|
||||
user.AvatarProvider != "initials" &&
|
||||
user.AvatarProvider != "upload" &&
|
||||
user.AvatarProvider != "marble" &&
|
||||
user.AvatarProvider != "ldap" {
|
||||
user.AvatarProvider != "ldap" &&
|
||||
user.AvatarProvider != "openid" {
|
||||
return updatedUser, &ErrInvalidAvatarProvider{AvatarProvider: user.AvatarProvider}
|
||||
}
|
||||
}
|
||||
|
||||
115
pkg/utils/avatar.go
Normal file
115
pkg/utils/avatar.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CropAvatarTo1x1 crops the avatar image to a 1:1 aspect ratio, centered on the image
|
||||
func CropAvatarTo1x1(imageData []byte) ([]byte, error) {
|
||||
if len(imageData) == 0 {
|
||||
return nil, errors.New("empty image data")
|
||||
}
|
||||
|
||||
// Decode the image
|
||||
img, format, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
// Get image dimensions
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
|
||||
// If already square, return original
|
||||
if width == height {
|
||||
return imageData, nil
|
||||
}
|
||||
|
||||
// Determine the crop size (use the smaller dimension)
|
||||
size := width
|
||||
if height < width {
|
||||
size = height
|
||||
}
|
||||
|
||||
// Calculate crop coordinates to center the image
|
||||
x0 := (width - size) / 2
|
||||
y0 := (height - size) / 2
|
||||
x1 := x0 + size
|
||||
y1 := y0 + size
|
||||
|
||||
// Create the cropping rectangle
|
||||
cropRect := image.Rect(x0, y0, x1, y1)
|
||||
|
||||
// Create a new RGBA image
|
||||
croppedImg := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
// Copy the cropped portion
|
||||
draw.Draw(croppedImg, croppedImg.Bounds(), img, cropRect.Min, draw.Src)
|
||||
|
||||
// Encode the result
|
||||
var buf bytes.Buffer
|
||||
switch format {
|
||||
case "jpeg":
|
||||
err = jpeg.Encode(&buf, croppedImg, nil)
|
||||
default:
|
||||
// Default to PNG if format is unknown
|
||||
err = png.Encode(&buf, croppedImg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode cropped image: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DownloadImage downloads an image from a URL and returns the image data
|
||||
func DownloadImage(url string) ([]byte, error) {
|
||||
// 3 seconds is enough for downloading an avatar
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to download image, status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
Reference in New Issue
Block a user