feat(auth): sync avatar from OpenID providers (#821)

This commit is contained in:
Weijie Zhao
2025-06-16 21:59:31 +08:00
committed by GitHub
parent 94ba911c0b
commit a214d68a44
9 changed files with 214 additions and 72 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)

View 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)
}

View File

@@ -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{}
}

View File

@@ -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

View File

@@ -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
View 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)
}